Today was a classic “it works on my machine… wait, no it doesn’t” day.
We hit a critical bug where the choice buttons in our stories simply stopped working. You’d click, and… nothing. No error, no movement. Just silence.
The Ghost in the Machine
The issue turned out to be how we were handling scripts. We were injecting the game logic (handling choices, calculating scores) directly into the HTML content.
In Astro, when you inject raw HTML using set:html, <script> tags don’t always behave the way you expect. They might get sanitized, or they might execute before the elements they’re supposed to control even exist.
The fix wasn’t to force the script to work, but to move it.
We migrated the logic out of the content and into a robust client-side script within the Astro component itself. We also had to explicitly attach our calculator functions to the global window object so they could talk to each other.
// Before: Hoping the script runs in the right scope
calculateAndDisplayPersonality(data); // ReferenceError!
// After: Explicitly attaching to the global window
if (typeof window.calculateAndDisplayPersonality === 'function') {
window.calculateAndDisplayPersonality(data);
}
It’s a good reminder: explicit is almost always better than implicit.
A Clean Slate
While we were deep in the logic, we noticed another UX quirk.
If a user played Frankenstein and then immediately started Pride and Prejudice, their choices from the first story were still hanging around in the browser’s storage. This meant their “Scientific Legacy” score might weirdly influence their “Romantic Hero” result.
We added a simple but crucial feature: Session Clearing.
Now, whenever you click “Start Your Journey” on a story cover, we wipe the slate clean.
startButton.addEventListener('click', () => {
sessionStorage.removeItem('userChoices');
// A fresh start for a new story
});
It’s a tiny change, but it ensures that every story is a fresh adventure.
Making Things Fast (Finally)
After fixing the bugs, I finally tackled something I’d been putting off: image optimization.
Our illustrations are beautiful, but they’re also heavy. We had 109 PNG files totaling 215MB just sitting in the public/ folder, being served exactly as-is. No compression, no modern formats, no responsive sizing.
Every story page was loading a 1200x800 PNG at full resolution, even on mobile devices.
I implemented Astro’s built-in Image component paired with Vercel’s Image Optimization API:
// Before: Plain old img tags
<img src="/stories/jekyll-and-hyde/opening_utterson.png" alt="Story scene" />
// After: Optimized, responsive, lazy-loaded
<Image
src={imageModule.default}
alt="Story scene"
width={1200}
height={800}
format="webp"
quality={85}
loading="lazy"
decoding="async"
/>
The process involved:
- Migration: Moving all images from
public/tosrc/assets/(where Astro can optimize them) - Dynamic imports: Using
import.meta.glob()to load images based on story slug - Configuration: Setting up responsive breakpoints [320, 640, 768, 1024, 1280, 1536]
- Format conversion: Automatic WebP/AVIF serving based on browser support
Expected results (once deployed):
- 70-85% image size reduction
- 3-5x faster load times
- Automatic lazy loading for images below the fold
- Responsive sizing (mobile gets mobile-sized images)
The best part? All of this happens automatically. The build process optimizes images once, and Vercel’s CDN serves the right format and size to each user.
No more waiting 5 seconds for a single story image to load.
What I Learned
- Scripts need explicit scopes - When in doubt, attach to
windowand check before calling - Session state is sticky - Clear it deliberately or face mysterious bugs
- Image optimization isn’t optional - Users on mobile networks will thank you
- Astro’s Image component is magic - Seriously, just use it from day one
Tomorrow: Actually telling people this thing exists (aka finally doing some marketing).