Converting my HTML, CSS and JavaScript site to use Svelte 5 with TypeScript and SvelteKit
Runes used in this project:
$props()
$state()
$effect()
$derived()
Grim Manor
Don't be scared—the original Grim Manor project is from a Halloween Hackathon I did with Samuel Anderson, Armando Urquiola Cabrera, Emma Lamont, and Jorgen Lovbakke. You are a stranger who wandered into an empty old mansion to escape the storm. On arriving, you're greeted by a pale young man, whom you deduce is a ghost. You must solve a few challenges to help him discover why he is stuck there.
Repos and deploy links
Deployed site - Original Grim Manor
Repo - Svelte 5 version of Grim Manor
Deployed site - Svelte 5 version of Grim Manor
Objective
This is the first project that I have converted to TypeScript and I wanted to see if there were any differences with the TypeScript in Svelte 5. The good news is there's actually not that much difference at all. Many of the Svelte 5 Runes just use standard typing. For example, let count: number = 0
would be let count: number = $state(0)
using the $state()
Rune. I'll discuss the other instances later.
Setup
Like in the other two projects mentioned in Converting my static site to use SvelteKit and Svelte 5 and Migrating HTML, CSS and JS site to Svelte 5 and SvelteKit, I needed to change the layout of the app.
The way we had set up the app was to break each minigame into its own JavaScript file and then display the minigame's code in a <dialog>
element when a certain door was clicked. With Svelte 5 and SvelteKit, I wanted to use a different approach that is less prone to quirky behaviour. I set up a (minigames)
folder (the brackets are around it so that it isn't included in the URL path), and then inside that I made a [game_name]
folder that would be used as the URL param for each game. I then added the +page.svelte
file and +page.ts
file to that folder. The code inside each of those is pretty short, so here it is:
+page.svelte
<script lang="ts"> import GameContainer from '$lib/components/GameContainer.svelte'; let { data } = $props(); let gameName: string = $derived(data.gameName); </script> <GameContainer {gameName} />
+page.ts
import type { PageLoad } from './$types'; export const load = (async ({ params }) => { return { gameName: params.game_name }; }) satisfies PageLoad;
I created a <GameContainer />
component. It takes the name of the game from the data object, which is automatically available to the +page.svelte
file after making the load function in the +page.ts
. There, we set the gameName
to the value of the params.game_name
, which is the URL param.
I also created a +layout.svelte
file in which I used the children()
snippet to render the content of the pages in the routes folder. This is where I found TypeScript to be a little different for Svelte 5. There is a Snippet
type in Svelte that can be imported, so it looks like this:
<script lang="ts"> import type { Snippet } from 'svelte'; let { children }: { children: Snippet } = $props(); </script>
LocalStorage and Audio objects
In the original game, we used localStorage, but from what I learned from the Joy of Code LocalStorage object using Svelte Runes tutorial to create a LocalStorage object for create state using the $state
Rune and updating it using $effect
.
In the original code for Grim Manor, we also had some logic for controlling audio, but I was able to use some similar logic as the LocalStorage object to create an Audio Manager object. I could then load the initial userData and the audioManager objects in the +layout.svelte
file, then pass them to the children pages using setContext
and getContext
.
While I created the setUserContext
and setAudioManagerContext
methods, I couldn't pass the values in there directly because I also needed the objects to be available in the +layout.svelte
files. As it turns out, context only makes an object available to the children. If you use setContext
to set the context of a userData object directly, that userData object can't be referenced within the +layout.svelte
file - at least that's my experience! If you know otherwise, please leave a comment.
In my case, the audio toggles live in the +layout.svelte
file, so the audio context needed to be available there and passed down to the children.
New content
We only had 5 days to brainstorm ideas, design, create content, develop and document the project. While I took a lot of the ideas from the original game, there were opportunities to update some of the content.
Ghostly orbs
On the mansion landing page, where the ghost appears, you could see some wands that indicated actions. These were good placeholders, but I wanted to add something ghost-like here. I ended up using a HTML element, and styling it with a background colour, opacity, and box shadow, and applying an animation to move along the SVG path.
Updated quiz
I used the quiz questions from the original project, bar 2 questions, to make it fit more with that olden-time theme. I also changed the background image to an elderly man to add to the story.
The structure of the quiz itself has also changed, as it was a great opportunity to use the $state
Rune. Like I mentioned earlier, there is nothing special we have to do in TypeScript for these Runes. Here's how they look:
let currentQuestionIndex: number = $state(0); let score: number = $state(0); let answerState: 'correct' | 'incorrect' | null = $state(null); let showScore: boolean = $state(false); let showEndCard: boolean = $state(false); let endText: string = $state(''); let quizStarted: boolean = $state(false);
As you can see, we simply add the typing as if there were no$state
there are all.
New dialogue
I like the dialogue of the original project, so it's the same for the most part. However, I did add some more for the quiz to be in line with the new background image. I also added dialogue for each minigame for when you win or lose and whether it's your first time playing or returning. Finally, there is some new dialogue for the endgame, but I won't give any spoilers.
The dialogues themselves did live in a dialogues.js
file and were exported. I thought it might be better in a Svelte project to keep everything encapsulated, so I moved each of the dialogues into their corresponding components.
Removed unnecessary code
Every developer's favourite thing to do. I got to remove a lot of JavaScript code that was made to work with the original <dialog>
popup for the minigames, or the many, many element selectors.
The complex logic we had for managing the audio and local storage could also be replaced by my LocalStorage and AudioManager spells.
Other issues faced
I faced a few challenges throughout the migration:
- The initial splash screen was always showing, even when the localStorage value meant it shouldn't. It would appear then disappear. As it turns out, this was caused by the fact that it was server-side rendered but then updated by localStorage. To stop this (and a few other annoying things happening), I made the app fully client side by adding this code to the
+layout.server.ts
file:
export const ssr = false;
In my
dialogues.js
dialogues, which I had originally added in a way that the user could make choices, which would then trigger JavaScript functions to update the DOM. One of the things it did was redirect to the homepage. While that works in Svelte, it still triggers a page reload, seemingly because the button is created after the DOM has already loaded. To tackle that, I instead made a game-end card that would only show up when theshowEndCard
state was true. That meant it wasn't generated using JavaScript and kept the page navigation nice and smooth.That darn audio. Even though the audio logic was simplified, I still ran into issues with playing or stopping music in the correct place. I knew I always wanted the same music in the minigames and different music playing on the mansion landing. To do that, I tracked the currently playing music in the
userData
object, updated it as the user navigated and then played the correct music when the toggle was enabled.
Project update on Stackportfolio
As usual, I am now able to add TypeScript
, Svelte
and SvelteKit
to the Grim Manor project's Stackportfol.io page. I also updated the links for the repo and the deployed site.
Conclusion
The migration took longer than I expected, but it was more due to the fact that I was removing old code and making updates. TypeScript with Svelte didn't add much complexity at all. As usual, I learned a lot and look forward to the next one!