Skip to content

H0W_T0: Pixel_Game

Here’s a beginner’s guide to creating a React farming simulation game. This application will feature animated game elements, in-game currency, and a day-night cycle, all rendered on an HTML canvas.

First, you’ll need Node.js and npm (or pnpm / yarn) installed on your machine. If you don’t have them, download them from the source links below:

  • inline Node.js & npm from official website
  • inline pnpm from official website
  • inline yarn from official website

Open your terminal or command prompt and run the following command to create a new React project using Vite (a fast build tool often used with React):

Terminal window
npm create vite@latest pixel-game -- --template react-ts
  • pixel-game: This specifies your project name.
  • --template react-ts: This specifies that you want a React project with TypeScript.

Navigate into your new project directory:

Terminal window
cd pixel-game

Install the necessary dependencies:

Terminal window
npm install

This project uses Tailwind CSS for styling. A utility-first CSS framework for rapidly building custom user interfaces. You can check Tailwind documentation using link below:

  • inline of Tailwind CSS from official website

Install it by running:

Terminal window
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

This will create tailwind.config.js and postcss.config.js files in your project root.

Open tailwind.config.js and configure the content array to scan all your React components for Tailwind classes:

tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {}
},
plugins: []
}

Open your main CSS file (e.g., src/index.css or src/App.css) and add the Tailwind directives:

src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Optional: Basic body styling for full-screen background */
body {
margin: 0;
padding: 0;
box-sizing: border-box;
background-color: #1a202c; /* Dark background */
font-family: 'Inter', sans-serif; /* Recommended font */
}
/* Basic styles for the game canvas container */
.canvas-card {
background-color: #2d3748; /* A dark slate background */
border-radius: 0.5rem;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
overflow: hidden; /* Ensures content stays within rounded corners */
display: flex;
justify-content: center;
align-items: center;
}
canvas {
display: block; /* Remove extra space below canvas */
/* The size of the canvas element itself will be set dynamically via React component props */
}

Create a new folder src/utils and recreate same files structure inside the folder using JavaScript code samples provided below.

src/utils/types.ts
// Type definitions for game data
export type PositionTurple = [number, number, number, number, number, number, number, number];
export interface SlotUpgrade {
price: number;
profitMultiplier: number;
}
export interface LevelSlot {
position: PositionTurple;
animationPosition: PositionTurple;
uiPosition: PositionTurple;
clickPosition: [number, number]; // [x_grid_unit, y_grid_unit]
price: number;
profitValue: number;
upgradesMultiplier: number;
}
export interface Level {
slots: Record<string, LevelSlot>;
slotsUpgrades: SlotUpgrade[];
slotStates: {
count: number;
updateCallback: (dayCycle: number) => number;
};
assets: {
url: string; // Base URL for assets
suffix: string; // e.g., '.png'
statesCount: number;
statesPrefix: string; // e.g., '/Farm_'
framesCount: number;
framesPrefix: string; // e.g., '/Farm_Animation_'
uiCount: number;
uiPrefix: string; // e.g., '/Farm_UI_'
};
ui: {
position: {
upgrades: PositionTurple;
money: PositionTurple;
time: PositionTurple;
};
upgradesPages: Array<{
slots: [number, number]; // [start_index, end_index] of slots for this page
position: PositionTurple;
}>;
};
}

Let’s break down the key parts of the src/utils/types.ts file:

  • PositionTurple: Defines an array for image drawing parameters (source X, Y, width, height, destination X, Y, width, height).
  • SlotUpgrade, LevelSlot, Level: Interfaces that precisely structure the game’s data, including prices, profits, positions, and asset configurations for different game elements (slots) and the overall level.
src/utils/levels.ts
import { type Level } from './levels'
// Game level configuration (farm level data)
const farm: Level = {
slots: {
grain: {
position: [4, 4, 5, 4, 4, 4, 5, 4],
animationPosition: [2, 4, 3, 2, 2, 4, 3, 2],
uiPosition: [0, 0.5, 3, 2, 0, 0.5, 3, 2],
clickPosition: [1, 1],
price: 5,
profitValue: 2,
upgradesMultiplier: 1
},
corn: {
position: [12, 3, 5, 5, 12, 3, 5, 5],
animationPosition: [16, 4, 3, 2, 16, 4, 3, 2],
uiPosition: [0, 2.5, 3, 2, 0, 2.5, 3, 2],
clickPosition: [1, 3],
price: 10,
profitValue: 3,
upgradesMultiplier: 2
},
cauliflower: {
position: [20, 4, 5, 4, 20, 4, 5, 4],
animationPosition: [21, 6, 1, 2, 21, 6, 1, 2],
uiPosition: [0, 4.5, 3, 2, 0, 4.5, 3, 2],
clickPosition: [1, 5],
price: 25,
profitValue: 4,
upgradesMultiplier: 5
},
cabbage: {
position: [28, 4, 5, 4, 28, 4, 5, 4],
animationPosition: [26, 3, 3, 2, 26, 3, 3, 2],
uiPosition: [0, 6.5, 3, 2, 0, 6.5, 3, 2],
clickPosition: [1, 7],
price: 45,
profitValue: 5,
upgradesMultiplier: 9
},
onion: {
position: [36, 4, 5, 4, 36, 4, 5, 4],
animationPosition: [40, 3, 3, 2, 40, 3, 3, 2],
uiPosition: [0, 8.5, 3, 2, 0, 8.5, 3, 2],
clickPosition: [1, 9],
price: 70,
profitValue: 6,
upgradesMultiplier: 14
},
carrot: {
position: [4, 9, 5, 4, 4, 9, 5, 4],
animationPosition: [7, 11, 1, 2, 7, 11, 1, 2],
uiPosition: [0, 10.5, 3, 2, 0, 10.5, 3, 2],
clickPosition: [1, 11],
price: 100,
profitValue: 7,
upgradesMultiplier: 20
},
tomato: {
position: [12, 8, 5, 5, 12, 8, 5, 5],
animationPosition: [14, 11, 1, 2, 14, 11, 1, 2],
uiPosition: [0, 12.5, 3, 2, 0, 12.5, 3, 2],
clickPosition: [1, 13],
price: 135,
profitValue: 8,
upgradesMultiplier: 27
},
radish: {
position: [20, 9, 5, 4, 20, 9, 5, 4],
animationPosition: [18, 8, 3, 2, 18, 8, 3, 2],
uiPosition: [0, 14.5, 3, 2, 0, 14.5, 3, 2],
clickPosition: [1, 15],
price: 175,
profitValue: 9,
upgradesMultiplier: 35
},
turnip: {
position: [28, 9, 5, 4, 28, 9, 5, 4],
animationPosition: [32, 9, 3, 2, 32, 9, 3, 2],
uiPosition: [0, 16.5, 3, 2, 0, 16.5, 3, 2],
clickPosition: [1, 17],
price: 220,
profitValue: 10,
upgradesMultiplier: 44
},
chiliPepper: {
position: [36, 9, 5, 4, 36, 9, 5, 4],
animationPosition: [39, 11, 1, 2, 39, 11, 1, 2],
uiPosition: [0, 18.5, 3, 2, 0, 18.5, 3, 2],
clickPosition: [1, 19],
price: 270,
profitValue: 11,
upgradesMultiplier: 54
},
zucchini: {
position: [4, 13, 5, 5, 4, 13, 5, 5],
animationPosition: [8, 13, 3, 2, 8, 13, 3, 2],
uiPosition: [0, 20.5, 3, 2, 0, 20.5, 3, 2],
clickPosition: [1, 21],
price: 325,
profitValue: 12,
upgradesMultiplier: 65
},
pumpkin: {
position: [12, 13, 5, 5, 12, 13, 5, 5],
animationPosition: [16, 16, 1, 2, 16, 16, 1, 2],
uiPosition: [0, 22.5, 3, 2, 0, 22.5, 3, 2],
clickPosition: [1, 23],
price: 385,
profitValue: 13,
upgradesMultiplier: 77
},
strawberry: {
position: [20, 14, 5, 4, 20, 14, 5, 4],
animationPosition: [23, 16, 1, 2, 23, 16, 1, 2],
uiPosition: [0, 24.5, 3, 2, 0, 24.5, 3, 2],
clickPosition: [1, 25],
price: 450,
profitValue: 14,
upgradesMultiplier: 90
},
grapes: {
position: [28, 13, 5, 5, 28, 13, 5, 5],
animationPosition: [32, 16, 1, 2, 32, 16, 1, 2],
uiPosition: [0, 26.5, 3, 2, 0, 26.5, 3, 2],
clickPosition: [1, 27],
price: 520,
profitValue: 15,
upgradesMultiplier: 104
},
berry: {
position: [36, 13, 5, 5, 36, 13, 5, 5],
animationPosition: [34, 14, 3, 2, 34, 14, 3, 2],
uiPosition: [0, 28.5, 3, 2, 0, 28.5, 3, 2],
clickPosition: [1, 29],
price: 595,
profitValue: 16,
upgradesMultiplier: 119
},
watermelon: {
position: [4, 18, 5, 5, 4, 18, 5, 5],
animationPosition: [5, 21, 1, 2, 5, 21, 1, 2],
uiPosition: [0, 30.5, 3, 2, 0, 30.5, 3, 2],
clickPosition: [1, 31],
price: 675,
profitValue: 17,
upgradesMultiplier: 135
},
pineapple: {
position: [12, 18, 5, 5, 12, 18, 5, 5],
animationPosition: [10, 18, 3, 2, 10, 18, 3, 2],
uiPosition: [0, 32.5, 3, 2, 0, 32.5, 3, 2],
clickPosition: [1, 33],
price: 760,
profitValue: 18,
upgradesMultiplier: 152
},
pricklyPear: {
position: [20, 18, 5, 5, 20, 18, 5, 5],
animationPosition: [24, 19, 3, 2, 24, 19, 3, 2],
uiPosition: [0, 34.5, 3, 2, 0, 34.5, 3, 2],
clickPosition: [1, 35],
price: 850,
profitValue: 19,
upgradesMultiplier: 170
},
cotton: {
position: [28, 19, 5, 4, 28, 19, 5, 4],
animationPosition: [30, 21, 1, 2, 30, 21, 1, 2],
uiPosition: [0, 36.5, 3, 2, 0, 36.5, 3, 2],
clickPosition: [1, 37],
price: 945,
profitValue: 20,
upgradesMultiplier: 189
},
appleTree: {
position: [3, 24, 16.5, 9, 3, 24, 16.5, 9],
animationPosition: [7, 30, 8, 3, 7, 30, 8, 3],
uiPosition: [0, 0.5, 3, 2, 0, 0.5, 3, 2],
clickPosition: [1, 1],
price: 1045,
profitValue: 22,
upgradesMultiplier: 209
},
orangeTree: {
position: [19.5, 24, 16.5, 9, 19.5, 24, 16.5, 9],
animationPosition: [23, 30, 4, 3, 23, 30, 4, 3],
uiPosition: [0, 2.5, 3, 2, 0, 2.5, 3, 2],
clickPosition: [1, 3],
price: 1155,
profitValue: 25,
upgradesMultiplier: 231
},
apricotTree: {
position: [3, 34, 16, 8, 3, 34, 16, 8],
animationPosition: [9, 40, 9, 2, 9, 40, 9, 2],
uiPosition: [0, 4.5, 3, 2, 0, 4.5, 3, 2],
clickPosition: [1, 5],
price: 1280,
profitValue: 28,
upgradesMultiplier: 256
},
lemonTree: {
position: [20, 33, 14, 9, 20, 33, 14, 9],
animationPosition: [23, 40, 7, 2, 23, 40, 7, 2],
uiPosition: [0, 6.5, 3, 2, 0, 6.5, 3, 2],
clickPosition: [1, 7],
price: 1420,
profitValue: 31,
upgradesMultiplier: 284
},
mapleTree: {
position: [49, 39, 7, 3, 49, 39, 7, 3],
animationPosition: [49, 39, 7, 3, 49, 39, 7, 3],
uiPosition: [0, 8.5, 3, 2, 0, 8.5, 3, 2],
clickPosition: [1, 9],
price: 1575,
profitValue: 34,
upgradesMultiplier: 315
},
pineTree: {
position: [63, 40, 2, 2, 63, 40, 2, 2],
animationPosition: [63, 40, 2, 2, 63, 40, 2, 2],
uiPosition: [0, 10.5, 3, 2, 0, 10.5, 3, 2],
clickPosition: [1, 11],
price: 1745,
profitValue: 37,
upgradesMultiplier: 349
},
oakTree: {
position: [73, 41, 2, 2, 73, 41, 2, 2],
animationPosition: [73, 41, 2, 2, 73, 41, 2, 2],
uiPosition: [0, 12.5, 3, 2, 0, 12.5, 3, 2],
clickPosition: [1, 13],
price: 1930,
profitValue: 40,
upgradesMultiplier: 386
},
chicken: {
position: [45, 4, 6, 5, 45, 4, 6, 5],
animationPosition: [45, 4, 6, 5, 45, 4, 6, 5],
uiPosition: [0, 0.5, 3, 2, 0, 0.5, 3, 2],
clickPosition: [1, 1],
price: 2130,
profitValue: 42,
upgradesMultiplier: 426
},
sheep: {
position: [54, 3, 6, 6, 54, 3, 6, 6],
animationPosition: [54, 3, 6, 6, 54, 3, 6, 6],
uiPosition: [0, 2.5, 3, 2, 0, 2.5, 3, 2],
clickPosition: [1, 3],
price: 2340,
profitValue: 44,
upgradesMultiplier: 468
},
cow: {
position: [62, 2, 8, 7, 62, 2, 8, 7],
animationPosition: [62, 2, 8, 7, 62, 2, 8, 7],
uiPosition: [0, 4.5, 3, 2, 0, 4.5, 3, 2],
clickPosition: [1, 5],
price: 2560,
profitValue: 46,
upgradesMultiplier: 512
},
pig: {
position: [72, 3, 6, 6, 72, 3, 6, 6],
animationPosition: [72, 3, 6, 6, 72, 3, 6, 6],
uiPosition: [0, 6.5, 3, 2, 0, 6.5, 3, 2],
clickPosition: [1, 7],
price: 2790,
profitValue: 48,
upgradesMultiplier: 558
},
duck: {
position: [45, 10, 7, 6, 45, 10, 7, 6],
animationPosition: [45, 10, 7, 6, 45, 10, 7, 6],
uiPosition: [0, 8.5, 3, 2, 0, 8.5, 3, 2],
clickPosition: [1, 9],
price: 3030,
profitValue: 50,
upgradesMultiplier: 606
},
furGoat: {
position: [54, 9, 6, 7, 54, 9, 6, 7],
animationPosition: [54, 9, 6, 7, 54, 9, 6, 7],
uiPosition: [0, 10.5, 3, 2, 0, 10.5, 3, 2],
clickPosition: [1, 11],
price: 3280,
profitValue: 52,
upgradesMultiplier: 656
},
goat: {
position: [63, 10, 6, 6, 63, 10, 6, 6],
animationPosition: [63, 10, 6, 6, 63, 10, 6, 6],
uiPosition: [0, 12.5, 3, 2, 0, 12.5, 3, 2],
clickPosition: [1, 13],
price: 3540,
profitValue: 54,
upgradesMultiplier: 708
},
donkey: {
position: [71, 10, 7, 6, 71, 10, 7, 6],
animationPosition: [71, 10, 7, 6, 71, 10, 7, 6],
uiPosition: [0, 14.5, 3, 2, 0, 14.5, 3, 2],
clickPosition: [1, 15],
price: 3810,
profitValue: 56,
upgradesMultiplier: 762
},
fish: {
position: [37, 28, 5, 9, 37, 28, 5, 9],
animationPosition: [37, 28, 5, 9, 37, 28, 5, 9],
uiPosition: [0, 16.5, 3, 2, 0, 16.5, 3, 2],
clickPosition: [1, 17],
price: 4090,
profitValue: 58,
upgradesMultiplier: 818
},
dog1: {
position: [53, 17, 7, 4, 53, 17, 7, 4],
animationPosition: [53, 17, 7, 4, 53, 17, 7, 4],
uiPosition: [0, 18.5, 3, 2, 0, 18.5, 3, 2],
clickPosition: [1, 19],
price: 4380,
profitValue: 60,
upgradesMultiplier: 876
},
dog2: {
position: [62, 17, 7, 4, 62, 17, 7, 4],
animationPosition: [62, 17, 7, 4, 62, 17, 7, 4],
uiPosition: [0, 20.5, 3, 2, 0, 20.5, 3, 2],
clickPosition: [1, 21],
price: 4680,
profitValue: 65,
upgradesMultiplier: 936
},
dog3: {
position: [73, 16, 5, 5, 73, 16, 5, 5],
animationPosition: [73, 16, 5, 5, 73, 16, 5, 5],
uiPosition: [0, 22.5, 3, 2, 0, 22.5, 3, 2],
clickPosition: [1, 23],
price: 5005,
profitValue: 70,
upgradesMultiplier: 1001
},
wateringCan: {
position: [71, 27, 1, 2, 71, 27, 1, 2],
animationPosition: [71, 27, 1, 2, 71, 27, 1, 2],
uiPosition: [0, 0.5, 3, 2, 0, 0.5, 3, 2],
clickPosition: [1, 1],
price: 7500,
profitValue: 104,
upgradesMultiplier: 1500
},
axe: {
position: [71, 27, 1, 2, 71, 27, 1, 2],
animationPosition: [71, 27, 1, 2, 71, 27, 1, 2],
uiPosition: [0, 2.5, 3, 2, 0, 2.5, 3, 2],
clickPosition: [1, 3],
price: 8225,
profitValue: 145,
upgradesMultiplier: 1645
},
scissors: {
position: [71, 27, 1, 2, 71, 27, 1, 2],
animationPosition: [71, 27, 1, 2, 71, 27, 1, 2],
uiPosition: [0, 4.5, 3, 2, 0, 4.5, 3, 2],
clickPosition: [1, 5],
price: 9240,
profitValue: 186,
upgradesMultiplier: 1848
},
fishingRod: {
position: [71, 27, 1, 2, 71, 27, 1, 2],
animationPosition: [71, 27, 1, 2, 71, 27, 1, 2],
uiPosition: [0, 6.5, 3, 2, 0, 6.5, 3, 2],
clickPosition: [1, 7],
price: 10545,
profitValue: 227,
upgradesMultiplier: 2109
},
shovel: {
position: [71, 27, 1, 2, 71, 27, 1, 2],
animationPosition: [71, 27, 1, 2, 71, 27, 1, 2],
uiPosition: [0, 8.5, 3, 2, 0, 8.5, 3, 2],
clickPosition: [1, 9],
price: 12130,
profitValue: 267,
upgradesMultiplier: 2426
}
},
slotsUpgrades: [
{ price: 5, profitMultiplier: 2 },
{ price: 10, profitMultiplier: 3 },
{ price: 15, profitMultiplier: 4 },
{ price: 20, profitMultiplier: 5 }
],
slotStates: {
count: 5,
updateCallback: (dayCycle: number) => (dayCycle > 0 ? Math.floor((dayCycle - 1) / 3) + 1 : 1)
},
// Using placeholder URLs for demonstration. Replace with actual asset paths.
assets: {
url: 'https://placehold.co/', // Base URL for placeholder images
suffix: '.png',
statesCount: 5,
statesPrefix: '48x48/CCCCCC/000000?text=S', // S1, S2, S3, S4, S5
framesCount: 32,
framesPrefix: '48x48/00FF00/000000?text=A', // A1, A2, ... A32
uiCount: 5,
uiPrefix: '48x48/0000FF/FFFFFF?text=UI', // UI1, UI2, ... UI5
},
ui: {
position: {
upgrades: [0, 0, 6, 40, 0, 0, 6, 40],
money: [0, 0, 11, 3, 69, 0, 11, 3],
time: [0, 3, 6, 1, 63, 1, 6, 1]
},
upgradesPages: [
{ slots: [0, 18], position: [4, 1, 1, 1, 4, 1, 1, 1] }, // Crop slots
{ slots: [19, 25], position: [4, 3, 1, 1, 4, 3, 1, 1] }, // Tree slots
{ slots: [26, 37], position: [4, 5, 1, 1, 4, 5, 1, 1] }, // Animal slots
{ slots: [38, 42], position: [4, 7, 1, 1, 4, 7, 1, 1] } // Tool slots
]
}
};
// Map of levels
export const levels: Record<string, Level> = { farm };

Let’s break down the key parts of the src/utils/levels.ts file:

  • farm object defines all the static data for the “farm” level:
    • slots: Details for each purchasable item (grain, corn, chicken, appleTree, etc.), including their positions on the screen (position), animation sprite coordinates (animationPosition), UI icon positions (uiPosition), and game mechanics like price, profitValue, and upgradesMultiplier.
    • slotsUpgrades: An array defining the price and profit multiplier for each upgrade level.
    • slotStates: Configuration for how many growth states a slot has and a callback to determine the current state based on the day cycle.
    • assets: Defines the base URL, suffix, and prefixes for loading all the game’s image assets (states, frames, UI, and font).
    • ui: Positions for general UI elements like upgrades menu, money display, and time display, as well as configurations for different pages within the upgrades menu.
src/utils/font.ts
// Font sprite sheet coordinates
export const font = {
en: {
a: [0, 0, 1, 1] // Example, actual char positions would be here
},
// Returns PositionTurple for a digit from the font sprite sheet
digits: (digit: number, position: [number, number, number, number]): PositionTurple => {
// [sprite_x, sprite_y, sprite_width, sprite_height, dest_x, dest_y, dest_width, dest_height]
return [digit, 17, 1, 1, position[0], position[1], position[2], position[3]] as PositionTurple;
}
};

Let’s break down the key parts of the src/utils/font.ts file:

  • Contains data for drawing text (specifically digits for money display) from a font sprite sheet. font.digits calculates the correct source and destination coordinates for drawing individual numbers.
src/utils/utils.ts
import { levels } from './levels'
import { font } from './font'
export { levels, font }

Let’s break down the key parts of the src/utils/utils.ts file:

  • The primary purpose of this file is to act as a central hub for all utility functions for our project. It imports functions from other files within the same /utils directory and then exports them all from this single file.

Create a new file src/Game.tsx and paste the entire React code provided below into it.

src/Game.tsx
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { levels, font } from './utils/utils'
import { type PositionTurple, type Level } from './utils/types'
// Helper function to get level config
function generateLevel(key: string): Level {
return levels[key];
}
// Global window properties (for animation loop IDs and game state for internal use by drawing logic)
// These are not ideal in React, but mirror the Solid.js global approach.
// In a real app, these setInterval IDs would be managed purely within refs/state.
declare global {
interface Window {
animationFrame: number;
animationLoop: number | NodeJS.Timeout | null;
purchaseAnimation: number | null;
dayLoop: number | NodeJS.Timeout | null;
gameState: {
levels?: {
[key: string]: {
slotsUpgrades: Record<string, number>;
slotsActive: Record<string, boolean>;
};
};
dayCycle?: number;
playerMoney?: number;
};
levelConfig?: Level; // Store current level config globally for drawing functions
}
}
const Game: React.FC = () => {
// Determine if the device is mobile based on screen width
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
// Reference to the canvas element
const canvasRef = useRef<HTMLCanvasElement>(null);
// State for the current level (fixed as 'farm' for this app)
const [level] = useState('farm');
// State to track if all level assets are loaded
const [levelLoaded, setLevelLoaded] = useState(false);
// State to store loaded Image objects for game assets
const [levelImages, setLevelImages] = useState<{
states?: HTMLImageElement[];
frames?: HTMLImageElement[];
ui?: HTMLImageElement[];
font?: HTMLImageElement;
}>({});
// State for active slots (plants/animals purchased) - loaded from localStorage
const [activeSlots, setActiveSlots] = useState<Record<string, boolean>>(() => {
const savedState = JSON.parse(localStorage.getItem('gameState') ?? '{}');
return savedState?.levels?.[level]?.slotsActive ?? {};
});
// State for slot upgrades - loaded from localStorage
const [slotsUpgrades, setSlotsUpgrades] = useState<Record<string, number>>(() => {
const savedState = JSON.parse(localStorage.getItem('gameState') ?? '{}');
return savedState?.levels?.[level]?.slotsUpgrades ?? {};
});
// State for current day cycle - loaded from localStorage
const [dayCycle, setDayCycle] = useState<number>(() => {
const savedState = JSON.parse(localStorage.getItem('gameState') ?? '{}');
return savedState?.dayCycle ?? 0;
});
// State for player money - loaded from localStorage
const [playerMoney, setPlayerMoney] = useState<number>(() => {
const savedState = JSON.parse(localStorage.getItem('gameState') ?? '{}');
return savedState?.playerMoney ?? 5; // Start with 5 money if no saved state
});
// State to control purchase animation feedback
const [purchaseAnimation, setPurchaseAnimation] = useState<string | false>(false);
// Refs for animation/interval IDs to manage them across re-renders
const animationFrameRef = useRef(0); // Current frame of the animation
const animationLoopId = useRef<NodeJS.Timeout | null>(null); // ID for the main animation loop
const purchaseAnimationFrameRef = useRef(0); // Frame for specific purchase animation
const dayLoopId = useRef<NodeJS.Timeout | null>(null); // ID for the day cycle loop
// State for current upgrades UI page
const [upgradesPage, setUpgradesPage] = useState<number>(0);
// Calculate dynamic canvas dimensions based on screen size and aspect ratio
const innerWidth = isMobile ? 360 : 720;
const innerHeight = isMobile ? 203 : 405;
const aspectRatio = innerWidth / innerHeight;
// Base unit (48px in original assets) scaled by aspect ratio
const aspectWidth = aspectRatio < 1.7777 ? innerWidth / 3840 : (innerHeight * 1.7777) / 3840;
const aspectHeight = aspectRatio > 1.7777 ? innerHeight / 2160 : (innerWidth / 1.7777) / 2160;
// Styles for the container div to ensure correct scaling
const styleWidth = `${aspectRatio < 1.7777 ? innerWidth : innerHeight * 1.7777}px`;
const styleHeight = `${aspectRatio > 1.7777 ? innerHeight : innerWidth / 1.7777}px`;
/**
* Helper function to convert grid positions to pixel styles for absolute positioning.
* @param posArray Array of grid positions [x, y, width, height]
* @returns CSS style object for positioning
*/
const styleFromPosition = useCallback((posArray: number[]) => {
// Assuming 1 grid unit = 48 pixels in the original asset design
const [gridX, gridY, gridWidth, gridHeight] = posArray;
return {
position: 'absolute' as const,
left: `${gridX * 48 * aspectWidth}px`,
top: `${gridY * 48 * aspectHeight}px`,
width: `${gridWidth * 48 * aspectWidth}px`,
height: `${gridHeight * 48 * aspectHeight}px`,
};
}, [aspectWidth, aspectHeight]); // Recalculate if aspect ratios change
/**
* Draws UI elements onto the canvas.
* @param ctx The canvas rendering context.
* @param uiPosition Object containing position data for UI elements.
*/
const updateUi = useCallback((ctx: CanvasRenderingContext2D, uiPosition: typeof levels.farm.ui.position) => {
if (!levelImages.ui || !levelImages.font) return; // Ensure images are loaded
// Draw upgrades UI page background
ctx.drawImage(
levelImages.ui[upgradesPage],
...(uiPosition.upgrades.map((pos, index) => pos * 48 + (index === 0 || index === 1 ? 6 : 0)) as PositionTurple)
);
// Draw money UI element
ctx.drawImage(
levelImages.ui[4], // Assuming UI asset index 4 is the money icon
...(uiPosition.money.map((pos) => pos * 48) as PositionTurple)
);
// Draw time (day cycle) UI element
// The original code uses .toSpliced() which is an immutable way to modify an array.
// Recreating the array for mapping:
const timePosAdjusted = [...uiPosition.time];
timePosAdjusted[0] = (dayCycle * 6); // Adjust X position for animation/day cycle
ctx.drawImage(
levelImages.ui[4], // Assuming UI asset index 4 is also used for time background
...(timePosAdjusted.map((pos) => pos * 48) as PositionTurple)
);
// Draw player money digits
const moneyString = playerMoney.toString().padStart(8, '0');
for (const [index, digitChar] of moneyString.split('').entries()) {
const digit = Number(digitChar);
// Calculate position for each digit on the UI
const digitPos: [number, number, number, number] = [71 + index, 1, 1, 1]; // Grid units
ctx.drawImage(
levelImages.font,
...(font.digits(digit, digitPos).map((pos) => pos * 48) as PositionTurple)
);
}
}, [levelImages, upgradesPage, dayCycle, playerMoney]); // Dependencies for updateUi
// Effect for loading all game assets
useEffect(() => {
const currentLevelConfig = generateLevel(level);
window.levelConfig = currentLevelConfig; // Store globally for canvas drawing functions
const loadSingleImage = (src: string): Promise<HTMLImageElement> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.src = src;
img.crossOrigin = 'anonymous'; // Important for CORS if images are from a different origin
img.onload = () => resolve(img);
img.onerror = (e) => {
console.error(`Failed to load image: ${src}`, e);
// Provide a fallback placeholder if image loading fails for a production app
const errorImg = new Image();
errorImg.src = `https://placehold.co/48x48/FF0000/FFFFFF?text=ERR`; // Generic error placeholder
errorImg.onload = () => resolve(errorImg);
errorImg.onerror = () => reject(new Error(`Fallback failed for ${src}`));
};
});
};
const loadAssets = async () => {
try {
const statesPromises = Array.from(new Array(currentLevelConfig.assets.statesCount).keys()).map(
i => loadSingleImage(`${currentLevelConfig.assets.url}${currentLevelConfig.assets.statesPrefix}${i + 1}${currentLevelConfig.assets.suffix}`)
);
const framesPromises = Array.from(new Array(currentLevelConfig.assets.framesCount).keys()).map(
i => loadSingleImage(`${currentLevelConfig.assets.url}${currentLevelConfig.assets.framesPrefix}${i + 1}${currentLevelConfig.assets.suffix}`)
);
const uiPromises = Array.from(new Array(currentLevelConfig.assets.uiCount).keys()).map(
i => loadSingleImage(`${currentLevelConfig.assets.url}${currentLevelConfig.assets.uiPrefix}${i + 1}${currentLevelConfig.assets.suffix}`)
);
const fontPromise = loadSingleImage(`${currentLevelConfig.assets.url}48x48/FF0000/FFFFFF?text=Font`); // Placeholder for font image
const [states, frames, ui, fontImage] = await Promise.all([
Promise.all(statesPromises),
Promise.all(framesPromises),
Promise.all(uiPromises),
fontPromise
]);
// Sort images by their number in the filename for correct ordering
states.sort((a, b) => {
const numA = Number(a.src.split(currentLevelConfig.assets.statesPrefix)[1].split(currentLevelConfig.assets.suffix)[0]);
const numB = Number(b.src.split(currentLevelConfig.assets.statesPrefix)[1].split(currentLevelConfig.assets.suffix)[0]);
return numA - numB;
});
frames.sort((a, b) => {
const numA = Number(a.src.split(currentLevelConfig.assets.framesPrefix)[1].split(currentLevelConfig.assets.suffix)[0]);
const numB = Number(b.src.split(currentLevelConfig.assets.framesPrefix)[1].split(currentLevelConfig.assets.suffix)[0]);
return numA - numB;
});
ui.sort((a, b) => {
const numA = Number(a.src.split(currentLevelConfig.assets.uiPrefix)[1].split(currentLevelConfig.assets.suffix)[0]);
const numB = Number(b.src.split(currentLevelConfig.assets.uiPrefix)[1].split(currentLevelConfig.assets.suffix)[0]);
return numA - numB;
});
setLevelImages({ states, frames, ui, font: fontImage });
animationFrameRef.current = 0; // Initialize animation frame
purchaseAnimationFrameRef.current = 0; // Initialize purchase animation frame
setLevelLoaded(true);
} catch (error) {
console.error("Error loading level assets:", error);
setLevelLoaded(false); // Indicate failure to load assets
}
};
loadAssets();
}, [level]); // Re-run when the 'level' state changes
// Effect for the main game animation loop
useEffect(() => {
if (!levelLoaded || !canvasRef.current || !levelImages.states || !levelImages.frames || !levelImages.ui) {
return; // Only run if assets are loaded and canvas is ready
}
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const levelSlots = window.levelConfig!.slots; // Access global config
const levelSlotsUpgrades = window.levelConfig!.slotsUpgrades;
const upgradesPages = window.levelConfig!.ui.upgradesPages;
// Clear any existing interval to prevent multiple loops
if (animationLoopId.current !== null) {
clearInterval(animationLoopId.current);
}
animationLoopId.current = setInterval(() => {
// Clear canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw base state (e.g., ground)
ctx.drawImage(levelImages.states![0], 0, 0, canvas.width, canvas.height); // Scale to canvas size
// Draw active slots (plants/animals)
for (const slotName of Object.keys(activeSlots)) {
const slotData = levelSlots[slotName];
if (!slotData) continue;
// Draw the state of the slot based on day cycle
const stateIndex = window.levelConfig!.slotStates.updateCallback(dayCycle);
ctx.drawImage(
levelImages.states![stateIndex],
...(slotData.position.map((pos) => pos * 48) as PositionTurple)
);
// Draw animation frame for the slot
ctx.drawImage(
levelImages.frames![animationFrameRef.current],
...(slotData.animationPosition.map((pos) => pos * 48) as PositionTurple)
);
}
// Update and draw UI elements
updateUi(ctx, window.levelConfig!.ui.position);
// Logic for drawing slot purchase/upgrade states in UI
const currentUpgradesPageSlots = upgradesPages[upgradesPage].slots;
const allSlotsKeys = Object.keys(levelSlots);
for (let i = currentUpgradesPageSlots[0]; i <= currentUpgradesPageSlots[1]; i++) {
const slotName = allSlotsKeys[i];
const slotData = levelSlots[slotName];
if (!slotData) continue;
// Determine position for UI overlay
const uiPos = slotData.uiPosition.map((pos, idx) => pos * 48 + (idx === 0 || idx === 1 ? 6 : 0));
// Handle purchase animation
if (purchaseAnimation === slotName) {
if (purchaseAnimationFrameRef.current === 4) { // Animation complete
setPurchaseAnimation(false);
purchaseAnimationFrameRef.current = 0; // Reset
} else {
// Draw animation frame for purchase
// Original Solid.js: .toSpliced(0, 1, (window.purchaseAnimation + 11) * 6)
const animUiPos: PositionTurple = [...uiPos];
animUiPos[0] = (purchaseAnimationFrameRef.current + 11) * 48; // Assume anim frame affects X (0th index)
ctx.drawImage(
levelImages.ui![upgradesPage], // Use current UI page image
...animUiPos as PositionTurple // Apply the adjusted position
);
purchaseAnimationFrameRef.current += 1;
}
} else {
// Draw regular UI state (purchased/can upgrade/can purchase)
let uiIndexOffset = 0; // Default to not affordable/not active
if (activeSlots[slotName]) {
// If slot is active (purchased)
const currentUpgradeLevel = slotsUpgrades[slotName] ?? 0;
const nextUpgradePrice = (levelSlotsUpgrades[currentUpgradeLevel]?.price ?? Infinity) * slotData.upgradesMultiplier;
if (playerMoney >= nextUpgradePrice) {
uiIndexOffset = 3; // Can upgrade
} else {
uiIndexOffset = 2; // Purchased, but cannot upgrade
}
} else {
// If slot is not active (not purchased)
if (playerMoney >= slotData.price) {
uiIndexOffset = 1; // Can purchase
}
}
// Original Solid.js: ((slotsUpgrades()[slot] ?? 0) * 2 + X) * 6
// This means: (current_upgrade_level * 2 + (2 or 3 or 1)) * 6
// 6 seems to be the base multiplier for UI sprite X coordinate
// Let's re-evaluate based on the original logic
let sourceXOffset = 0;
if (activeSlots[slotName]) {
const currentUpgradeLevel = slotsUpgrades[slotName] ?? 0;
const nextUpgradePrice = (levelSlotsUpgrades[currentUpgradeLevel]?.price ?? Infinity) * slotData.upgradesMultiplier;
if (playerMoney >= nextUpgradePrice) {
sourceXOffset = (currentUpgradeLevel * 2 + 3) * 6 * 48; // Can upgrade
} else {
sourceXOffset = (currentUpgradeLevel * 2 + 2) * 6 * 48; // Cannot upgrade
}
} else {
if (playerMoney >= slotData.price) {
sourceXOffset = 6 * 48; // Can purchase
} else {
sourceXOffset = 0; // Cannot purchase
}
}
// Apply this calculated sourceXOffset to the UI sprite.
// The original drawing logic: ctx.drawImage(levelImages().ui![upgradesPage()], ...(levelSlots[slot].uiPosition!.toSpliced(0, 1, calculated_x_pos).map(...)))
// This implies the uiPosition first value (index 0) is the x-coordinate * 48 of the source image.
// The source image's actual x coordinate would be sourceXOffset (if original uiPosition[0] was 0)
// Simplified: Assuming uiPosition[0] is always 0 and 6 * 48 is the actual unit step on the sprite sheet.
const uiSpriteSourceX = Math.floor(sourceXOffset / 48); // Convert back to grid unit for original uiPosition logic
const finalUiPos: PositionTurple = [...slotData.uiPosition];
finalUiPos[0] = uiSpriteSourceX; // Update the source X for drawing
ctx.drawImage(
levelImages.ui![upgradesPage], // Current upgrades page UI image
// Source X, Source Y, Source Width, Source Height (from slotData.uiPosition)
finalUiPos[0] * 48, finalUiPos[1] * 48, finalUiPos[2] * 48, finalUiPos[3] * 48,
// Destination X, Destination Y, Destination Width, Destination Height (scaled by aspect ratios)
slotData.uiPosition[4] * 48 * aspectWidth, slotData.uiPosition[5] * 48 * aspectHeight,
slotData.uiPosition[6] * 48 * aspectWidth, slotData.uiPosition[7] * 48 * aspectHeight
);
}
}
// Advance animation frame
animationFrameRef.current = (animationFrameRef.current + 1) % levelImages.frames.length;
}, 100); // 100ms interval for animation frames (10 FPS)
// Cleanup: Clear the interval when component unmounts or dependencies change
return () => {
if (animationLoopId.current !== null) {
clearInterval(animationLoopId.current);
animationLoopId.current = null;
}
};
}, [levelLoaded, levelImages, activeSlots, dayCycle, upgradesPage, purchaseAnimation, playerMoney, updateUi, aspectWidth, aspectHeight]);
// Effect for the day cycle loop
useEffect(() => {
if (!levelLoaded) {
return; // Only run if assets are loaded
}
// Clear any existing interval
if (dayLoopId.current !== null) {
clearInterval(dayLoopId.current);
}
dayLoopId.current = setInterval(() => {
setDayCycle((prevDay) => {
const nextDay = prevDay === 12 ? 0 : prevDay + 1;
if (nextDay === 0) { // If it's a new day (after day 12)
let totalProfit = 0;
const currentLevelConfig = window.levelConfig!;
for (const slotName of Object.keys(activeSlots)) {
const slotData = currentLevelConfig.slots[slotName];
const upgradeLevel = slotsUpgrades[slotName] ?? 0;
const profitMultiplier = currentLevelConfig.slotsUpgrades[upgradeLevel - 1]?.profitMultiplier ?? 1;
totalProfit += profitMultiplier * slotData.profitValue;
}
setPlayerMoney((prevMoney) => prevMoney + Math.ceil(totalProfit));
}
return nextDay;
});
}, 500); // 500ms per day cycle step
// Cleanup: Clear the interval when component unmounts or dependencies change
return () => {
if (dayLoopId.current !== null) {
clearInterval(dayLoopId.current);
dayLoopId.current = null;
}
};
}, [levelLoaded, activeSlots, slotsUpgrades]); // Dependencies for day cycle loop
// Effect for saving game state to localStorage
useEffect(() => {
if (levelLoaded) {
const currentGameState = {
levels: {
[level]: {
slotsActive: activeSlots,
slotsUpgrades: slotsUpgrades
}
},
dayCycle: dayCycle,
playerMoney: playerMoney
};
localStorage.setItem('gameState', JSON.stringify(currentGameState));
}
}, [levelLoaded, level, activeSlots, slotsUpgrades, dayCycle, playerMoney]); // Save whenever relevant state changes
return (
<div className="relative overflow-hidden flex flex-col items-center justify-center min-h-screen bg-gray-900 text-white p-4">
<div
id="game-canvas-div"
className={
isMobile ? `canvas-card relative h-[203px] w-[360px]` : `canvas-card relative h-[405px] w-[720px]`
}
>
<div
className={isMobile ? `h-[203px] w-[360px]` : `h-[405px] w-[720px]`}
style={{ zIndex: 2, position: 'absolute', top: 0, left: 0, display: 'flex', justifyContent: 'center', alignItems: 'center' }}
>
{levelLoaded && window.levelConfig && (
<div style={{ width: styleWidth, height: styleHeight, position: 'relative' }}>
{/* Render interactive click areas for slots */}
{Object.entries(window.levelConfig.slots)
.slice(
window.levelConfig.ui.upgradesPages[upgradesPage].slots[0],
window.levelConfig.ui.upgradesPages[upgradesPage].slots[1] + 1
)
.map(([slotName, slotData]) => (
<div
key={slotName} // Use slotName as key
id={slotName}
style={styleFromPosition(slotData.clickPosition.concat([1,1]))} // clickPosition is [x,y], add dummy width/height for styleFromPosition to work
onClick={() => {
if (activeSlots[slotName] == null) { // If slot is not yet active (not purchased)
if (slotData.price <= playerMoney) {
setPurchaseAnimation(slotName);
setPlayerMoney((prev) => prev - slotData.price);
setActiveSlots((prev) => ({ ...prev, [slotName]: true }));
}
} else { // Slot is active, attempt to upgrade
const currentUpgradeLevel = slotsUpgrades[slotName] ?? 0;
const nextUpgradePrice = (window.levelConfig!.slotsUpgrades[currentUpgradeLevel]?.price ?? Infinity) * slotData.upgradesMultiplier;
if (nextUpgradePrice <= playerMoney) {
setPurchaseAnimation(slotName);
setPlayerMoney((prev) => prev - nextUpgradePrice);
setSlotsUpgrades((prev) => ({ ...prev, [slotName]: (prev[slotName] ?? 0) + 1 }));
}
}
}}
></div>
))}
{/* Render interactive click areas for upgrades UI page navigation */}
{window.levelConfig.ui.upgradesPages.map((upgradesPageConfig, index) => (
<div
key={`upgradesPage_${index}`}
id={`upgradesPage_${index}`}
style={styleFromPosition(upgradesPageConfig.position.concat([1,1]))} // PositionTurple is [x,y,w,h,x,y,w,h], styleFromPosition expects [x,y,w,h]
onClick={() => {
setUpgradesPage(index);
}}
></div>
))}
</div>
)}
</div>
<div
className={isMobile ? `!mt-0 h-[203px] w-[360px]` : `!mt-0 h-[405px] w-[720px]`}
style={{ zIndex: 1, position: 'absolute', top: 0, left: 0, display: 'flex', justifyContent: 'center', alignItems: 'center' }}
>
<canvas
id="game-canvas"
ref={canvasRef} // Assign ref to canvas
width="3840" // Internal drawing resolution
height="2160"
></canvas>
</div>
</div>
</div>
);
};
export default Game;

Let’s break down the key parts of the src/Game.tsx file:

  • useState Hooks:
    • isMobile: Detects mobile screen size for responsive adjustments.
    • level: The currently active game level (fixed to ‘farm’).
    • levelLoaded: Boolean to indicate if all game assets have been successfully loaded.
    • levelImages: Stores the loaded HTMLImageElement objects for rendering.
    • activeSlots: Tracks which slots the player has purchased. This, along with slotsUpgrades, dayCycle, and playerMoney, is initialized from localStorage to persist game state across sessions.
    • purchaseAnimation: Manages a temporary state to trigger a visual animation when an item is purchased or upgraded.
    • upgradesPage: Controls which page of the upgrades UI is currently displayed.
  • useRef Hooks:
    • canvasRef: Provides a direct reference to the <canvas/> DOM element for drawing.
    • animationFrameRef, animationLoopId, purchaseAnimationFrameRef, dayLoopId: These useRef instances are crucial for managing the game’s animation and game loops (setInterval IDs and animation frame counters) without causing excessive re-renders, while still allowing access and modification across renders.
  • Dimension Calculations (innerWidth, innerHeight, aspectWidth, aspectHeight, styleWidth, styleHeight):
    • These variables dynamically calculate the appropriate scaling factors and display sizes for the canvas and UI elements based on the screen’s aspect ratio and whether it’s a mobile device. This ensures the game scales correctly on different devices.
  • styleFromPosition and updateUi useCallback functions:
    • styleFromPosition: A memoized helper that converts abstract grid positions from the farm config into actual pixel values for CSS positioning.
    • updateUi: A memoized function responsible for drawing all the UI elements (upgrades menu, money display, time/day cycle) onto the canvas, utilizing the levelImages and current game state.
  • useEffect Hooks (Game Logic Flow):
    • Resize Listener: The first useEffect adds and cleans up a resize event listener to update isMobile.
    • Asset Loading: A critical useEffect loads all game image assets (states, animation frames, UI elements, font) asynchronously using Promise.all. It sets levelLoaded to true once all assets are ready, allowing the game loops to begin. This effect handles dynamic URLs, making it easier to integrate placeholder images or eventually local assets.
    • Main Animation Loop: This useEffect sets up the setInterval for the core game animation. It clears the canvas, draws the background, active slots (with their current growth state), and animation frames. It also handles the visual feedback for purchases/upgrades and calls updateUi to render score and time.
    • Day Cycle Loop: A separate useEffect manages the game’s day cycle, which triggers profit calculation at the end of each full cycle (day 12).
    • State Persistence: Another useEffect saves the current activeSlots, slotsUpgrades, dayCycle, and playerMoney to localStorage whenever these states change, ensuring the player’s progress is saved.
  • JSX Structure:
    • The main game area is composed of two overlapping div elements: one for the interactive click areas (zIndex: 2) and another for the actual canvas drawing (zIndex: 1).
    • Interactive click areas (div elements with onClick handlers) are dynamically rendered based on the levelConfig. These invisible divs overlay the canvas and handle the game’s core interactions: purchasing new slots or upgrading existing ones, and navigating upgrades pages.
    • The <canvas/> element is where all the game’s visual elements are drawn using its 2D rendering context.

Modify src/App.tsx to render your Game component:

src/App.tsx
import React from 'react'
import Game from './Game' // Import your Game component
import './index.css' // Ensure your Tailwind CSS is imported
function App() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-900 p-4">
<Game />
</div>
)
}
export default App

Start the development server:

Terminal window
npm run dev

Open your web browser and navigate to the address shown in your terminal (usually http://localhost:5173). You should see your farming game running with placeholder images.

This application demonstrates a powerful concept: building a complete interactive game within a web browser using React and HTML Canvas. It showcases how to:

  • Manage complex game state with React hooks.
  • Dynamically load and manage multiple image assets for animations and UI.
  • Draw intricate game scenes directly onto an HTML canvas.
  • Implement game loops (animation, day cycle) using setInterval with proper cleanup.
  • Handle user interactions (clicks) for game mechanics like purchasing and upgrading.
  • Persist game data using localStorage, allowing players to save their progress.
  • Design a game that is responsive to different screen sizes. This project serves as an excellent foundation for more complex browser-based game development.
  • Implement Real Assets: Replace the placehold.co URLs with your actual game sprites and place them in the public/media/Farm directory. This is crucial for the game’s visual appeal.
  • Improve UI/UX:
    • Add visual feedback (e.g., text overlays, subtle animations) for when purchases or upgrades are not affordable.
    • Create actual UI elements for the upgrades pages instead of just invisible click areas, displaying prices and upgrade benefits.
    • Consider adding sound effects for purchases, profits, and other events.
  • Expand Game Mechanics:
    • Introduce different types of levels with unique slots and assets.
    • Add more complex upgrade trees or item interactions.
    • Implement an inventory system for collected items.
    • Add challenges, quests, or achievements.
  • Performance Optimization: For more complex games, consider:
    • Off-screen Canvas/Web Workers: Perform heavy drawing calculations or game logic in a Web Worker to keep the main thread free and UI responsive.
    • Sprite Sheet Optimization: Ensure your asset sprite sheets are efficiently packed.
    • Drawing Optimizations: Reduce redundant drawing operations on the canvas.
  • Error Handling: Implement more robust error handling for asset loading and game logic.
  • Authentication & Cloud Save: For a persistent multiplayer or cross-device experience, integrate a backend (e.g., Firebase Firestore as suggested by the platform instructions) to save game state in the cloud instead of just localStorage.

Visual appearance of the project in guide and showcase can be different