You'll make a bunny themed tycoon style idle game. Try the live demo.
This workshop will teach you the basics of building a frontend web app using Svelte and SvelteKit. You'll learn concepts such as components and reactivity using Svelte, but these concepts can be easily applied to other frontend frameworks such as React. You'll also gain experience using TypeScript to write statically typed JavaScript and TailwindCSS to write inline CSS.
You'll be expected to have a basic understanding and familiarity with basic web development including HTML, CSS, and JavaScript. If you don't have any prior experience however, you can still follow along and learn along the way.
A partially completed skeleton of the Svelte project, including assets created by me.
A Codespace you can use to follow along— but feel free to clone this repository and follow along locally if you know what you're doing.
Let's begin by going over the project's directory structure. In the root you'll find configuration files for our project and tooling. For example, the package.json file defines which packages Node Package Manager or npm needs to install to set up our project. Don't touch these unless you know what you're doing as it could lead to things breaking unexpectedly.
Next we have our src folder. As the name suggests, this is where our source code lives. For the purposes of this tutorial, we will be working in the lib and routes folders and you don't need to touch app.html, app.d.ts, nor app.css.
We'll begin by creating a new Codespace in this repository. Using Codespaces will allow you to follow along without having to worry about configuring your dependencies locally. However, if you already know what you're doing, feel free to clone this repository and npm i.
Start your project and you should see a main menu.
The first thing we'll do is to create the main menu for our game. Start by opening src/routes/+page.svelte.
SvelteKit uses filesystem-based routing, so if you wanted to add a route when the user visits
/play, you would put it undersrc/routes/playand similarly, to define the page that shows when the user visits the root url at/, you would place it undersrc/routes.+layout.svelteis used to define the general layout of the route, that is shared between routes of the same level, while+page.svelteis used to define the contents of that specific route. For now, don't worry about+layout.svelte
Svelte files have three main sections: your JavaScript code, which goes in a script tag, your HTML markup, which doesn't go in any tag, and your CSS, which goes in a style tag. It should feel as if you're writing an HTML file, but adding JS and CSS to it.
In the script tag, add these import statements: (1)
import { goto } from "$app/navigation";
import Button from "$lib/components/button.svelte";goto is a built in Svelte function that navigates the user to a different page.
Button is a custom button component I've included that is pre-styled so you don't have to worry about styling it.
Find the empty div and create a new Button component (2)
<Button
type="accent"
onclick={() => {
goto("/play");
}}
>
New Game
</Button>Make sure your button has the the attributes type="accent" and an onclick event which navigates the user to the /play page. /play is where the main game will be.
When you click on the button, it should navigate you to the /play page
We'll be storing the game state in the src/lib/state.svelte.ts file. The .svelte.ts extension allows for the use of reactive runes such as $state to be used outside of .svelte files.
Let's start by creating a TypeScript interface to define the structure of our game state.
export interface GameState {
balance: number;
bunny: number;
grass: number;
flower: number;
tree: number;
}Next, we can create an object that will store the initial or default game state
const DEFAULT_STATE: GameState = {
balance: 0,
bunny: 1,
grass: 0,
flower: 0,
tree: 0,
};Create reactive objects that we'll use to store data across our app
// Create a reactive object to store the game tick's setInterval timer
export const gameTickInterval: { current: number } = $state({ current: 0 });
// Create a reactive object to store the current game state
export const gameState: { current: GameState | null } = $state({
current: Object.assign({}, DEFAULT_STATE),
});
// Create a reactive object to store the player's current money per second
export const moneyPerSecond: { current: number } = $state({ current: 1 });Create a function to calculate the rate of money earned based on the current game state
export function calculateRate(state: GameState): number {
if (state === null) return 0;
let rate = 1;
rate += state.grass * 1;
rate += state.flower * 5;
rate *= 1.1 ** state.tree;
return rate;
}Create a function that resets the game state and starts a new game
export function newGame(): void {
gameState.current = Object.assign({}, DEFAULT_STATE);
console.log(DEFAULT_STATE);
localStorage.setItem("state", JSON.stringify(DEFAULT_STATE));
moneyPerSecond.current = 10;
}Go back to the src/routes/+page.svelte file, and change the New Game button to the following:
{#if gameState !== null}
<Button
type="accent"
onclick={() => {
goto("/play");
}}
>Continue
</Button>
{/if}
<Button
type={gameState === null ? "accent" : "neutral"}
onclick={() => {
newGame();
goto("/play");
}}
>New Game
</Button>This uses a Svelte if block to check whether there exists a current game state, and if so, give the user the option to continue their game. And in both cases, the new game button is shown. Note that the button type for the New Game button uses a ternary operator to set its style depending on whether a game state exists or not.
Now that we have a main menu from which the player can create a new game, we need to create the game for the player to play.
Start by opening src/routes/(game)/+layout.svelte.
(game)is an example of what's called a route group in SvelteKit. It allows layouts to be shared between different routes without affecting the route itself. For example, if we were to add multiple game modes, they could be under different routes, but share the same overall layout.
Start by creating a game loop using the JavaScript setInterval method. (1)
onMount(() => {
// This is where we will calculate the new balance each game tick
window.clearInterval(gameTickInterval.current);
gameTickInterval.current = window.setInterval(() => {
if (gameState.current === null) return;
gameState.current.balance += gameState.current.bunny * moneyPerSecond.current;
}, 1000);
});onMount is a Svelte function that schedules a callback to run when the component, or in this case, the layout, has been mounted to the DOM. We need this since we're using the Window APIs which are only available on the browser, and don't exist yet when Svelte is trying to render the page on the server.
Next, we're going to create a reactive variable we can bind our Shop component to (2)
let shop = $state<Shop>();$state is a special Svelte rune that tells the Svelte compiler that this variable will change and to watch for changes, and reactively update the DOM as needed. In this case, we will be binding the Shop component to it, which means, use this variable to reference this component.
You can now go to the bottom of the file and add our Shop component and bind it to the variable we just created
<Shop bind:this={shop} />Next, we can create a mechanism to update our local storage when our game state has changed. (3)
$effect(() => {
if (gameState.current === null) {
localStorage.removeItem("state");
} else {
moneyPerSecond.current = calculateRate(gameState.current);
localStorage.setItem("state", JSON.stringify(gameState.current));
}
});Now we can add in the elements that make up our game's UI. Let's fill in the contents of the p tags. For the top left section:
{gameState.current?.balance.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}{moneyPerSecond.current.toLocaleString("en-US", {
style: "currency",
currency: "USD",
})}/<Bunny size={30} />/secIn Svelte, you can add reactive content in your HTML just by using curly braces. Inside, you can reference your reactive variables, and they will update on their own (just make sure you use $state), or any JS expression.
For the top right section, create a button that navigates the user back to the main menu. For the button contents we will use the Back2Fill icon from the Mingcute icon library.
<Button
onclick={() => {
goto("/");
}}
><MingcuteBack2Fill />
</Button>You can leave the middle section as is. {@render children()} is used in layout files to tell SvelteKit where to put the contents of the +page.svelte file.
Now for the bottom left section, create a button to open the shop:
<Button
type="accent"
onclick={() => {
shop?.show();
}}
><MingcuteShoppingCart1Fill /> Shop
</Button>And for the bottom right, we'll add our resource counters (bunnies, grasses, flowers, trees).
Each counter should look like this. It has an icon and a reactively updated counter for the resource.
<span class="text-accent-content flex items-center gap-1 text-3xl font-semibold">
<div class="drop-shadow-sm">
<Bunny size={40} />
</div>
<p>{gameState.current?.bunny}</p>
</span>Repeat this for the rest of the resources, Grass, Flower, and Tree.
Now it's time to create the graphical portion of the game, that shows the scene with our bunnies, grass, trees, and flowers.
Start by opening src/routes/(game)/play/+page.svelte
We'll use two external libraries, alea and simplex-noise to generate some randomness for our game. (1)
const prng = Alea("stony-brook");
const noise2D = createNoise2D(prng);Next, we'll create the container to hold all the game "objects" and also act as a background. We'll create a div that spans the full width and height and give it a sky colored background. We'll also make it relatively positioned, so we can use absolute positioning later with the child elements.
<div class="relative h-full w-full bg-sky-200"></div>Inside, we'll create a Svelte if block to only render the contents inside if the page has been fully loaded.
<div class="relative h-full w-full bg-sky-200">
{#if ready}
<!-- Create the trees -->
<!-- Create the grass -->
<!-- Create the flowers -->
<!-- Create the bunnies -->
{/if}
</div>The basic structure is the same for all of the objects, with slight tweaks to how they're positioned so they don't all get positioned in the same spot.
Trees:
<div class="absolute inset-0">
{#each Array(gameState.current?.tree) as _, i}
<div
class="absolute bottom-35 -translate-x-1/2"
style="left: {noise2D(i * 1.3, i / 2) * 50 + 50}%;"
>
<Tree />
</div>
{/each}
</div>Grass (this one gets a grass colored background, which happens to be the accent color in our daisyUI theme, which is why bg-accent is used):
<div class="bg-accent absolute bottom-0 h-40 w-full">
{#each Array(gameState.current?.grass) as _, i}
<div
class="absolute -translate-x-1/2 -translate-y-1/2"
style="left: {noise2D(i, i) * 50 + 50}%; bottom: {100 * Math.sin(i * 7) - 40}%"
>
<Grass />
</div>
{/each}
</div>Flowers:
<div class="absolute bottom-0 h-40 w-full">
{#each Array(gameState.current?.flower) as _, i}
<div
class="absolute -translate-x-1/2 -translate-y-1/2"
style="left: {noise2D(i * 2, i - 15) * 50 + 50}%; bottom: {100 * Math.sin(i * 2) - 40}%"
>
<Flower />
</div>
{/each}
</div>And lastly, the bunnies:
<div class="absolute bottom-0 h-40 w-full">
{#each Array(gameState.current?.bunny) as _, i}
<Mover {i} bottom="{100 * Math.sin(i * 2) - 40}%">
<div class="hop" style="animation-delay: {i * 100}ms;">
<Bunny />
</div>
</Mover>
{/each}
</div>This is what it should look like once you're done
<!-- Create a container to house the environment and give it a sky colored background -->
<div class="relative h-full w-full bg-sky-200">
{#if ready}
<!-- Create the trees -->
<div class="absolute inset-0">
{#each Array(gameState.current?.tree) as _, i}
<div
class="absolute bottom-35 -translate-x-1/2"
style="left: {noise2D(i * 1.3, i / 2) * 50 + 50}%;"
>
<Tree />
</div>
{/each}
</div>
<!-- Create the grass -->
<div class="bg-accent absolute bottom-0 h-40 w-full">
{#each Array(gameState.current?.grass) as _, i}
<div
class="absolute -translate-x-1/2 -translate-y-1/2"
style="left: {noise2D(i, i) * 50 + 50}%; bottom: {100 * Math.sin(i * 7) - 40}%"
>
<Grass />
</div>
{/each}
</div>
<!-- Create the flowers -->
<div class="absolute bottom-0 h-40 w-full">
{#each Array(gameState.current?.flower) as _, i}
<div
class="absolute -translate-x-1/2 -translate-y-1/2"
style="left: {noise2D(i * 2, i - 15) * 50 + 50}%; bottom: {100 * Math.sin(i * 2) - 40}%"
>
<Flower />
</div>
{/each}
</div>
<!-- Create the bunnies -->
<div class="absolute bottom-0 h-40 w-full">
{#each Array(gameState.current?.bunny) as _, i}
<Mover {i} bottom="{100 * Math.sin(i * 2) - 40}%">
<div class="hop" style="animation-delay: {i * 100}ms;">
<Bunny />
</div>
</Mover>
{/each}
</div>
{/if}
</div>Now it's time to build out the shop so players can purchase more resources and upgrade their bunny field.
Open the src/lib/components/shop/shop.svelte file and create a reactive variable to handle the dialog element. (1)
let modal = $state<HTMLDialogElement>();Create functions to open and close the modal. (2)
export const show = () => {
modal?.showModal();
};
export const close = () => {
modal?.close();
};Use the $derived rune to reactively calculate the price of each resource based on the amount already purchased. (3)
let grassPrice: number = $derived(10 * 1.2 ** (gameState.current?.grass || 0));
let flowerPrice: number = $derived(50 * 1.2 ** (gameState.current?.flower || 0));
let treePrice: number = $derived(100 * 1.23 ** (gameState.current?.tree || 0));
let bunnyPrice: number = $derived(1000 * 2 ** ((gameState.current?.bunny || 1) - 1));Now, open the src/lib/components/shop/shop-item.svelte file and create the component props. These define the atributes your component exposes to the parent, and defines what information the parent can pass into the component.
let {
children,
owned = $bindable(),
price,
item,
}: {
children?: Snippet;
owned: number;
price: number;
item: string;
} = $props();$bindable allows the prop to be binded to, meaning the data can change inside the component if it's changed outside the component.
Next, fill in the rest of the component with this data. Note the button, which has an onclick event handler which checks if the player's balance is greater than or equal to the price of the resource and purchases it.
<p class="text-accent-content text-3xl font-semibold">{owned}</p>
<div class="flex flex-1 items-center justify-end gap-2">
<p class="text-primary-content text-3xl font-semibold">
{price.toLocaleString("en-US", { style: "currency", currency: "USD" })}
</p>
<Button
type="accent"
onclick={() => {
if (gameState.current === null) return;
if (gameState.current.balance >= price) {
gameState.current.balance -= price;
owned++;
}
}}
>Purchase {item}
</Button>
</div>Now, you can go back to your shop.svelte component and add a ShopItem component for each of the purchasable resources
<ShopItem item="Grass" price={grassPrice} bind:owned={gameState.current.grass}>
<Grass size={40} />
</ShopItem>
<ShopItem item="Flower" price={flowerPrice} bind:owned={gameState.current.flower}>
<Flower size={40} />
</ShopItem>
<ShopItem item="Tree" price={treePrice} bind:owned={gameState.current.tree}>
<Tree size={40} />
</ShopItem>
<ShopItem item="Bunny" price={bunnyPrice} bind:owned={gameState.current.bunny}>
<Bunny size={40} />
</ShopItem>Your game should be fully playable now. While this workshop didn't go too in depth into the intricacies of Svelte, you should have a general idea of how to navigate and build a SvelteKit application. To start your own project using SvelteKit, I recommend you read the SvelteKit docs.
We also didn't go over the game components, bunny.svelte, mover.svelte, etc. but they should be simple enough to understand. Feel free to play around with the code and modify things to your heart's content.