In part 2, Last Train became a real, winnable game: hotspots, an inventory and data-driven puzzles. But it's silent, it has no story, and refreshing the page throws away your progress.
This final part turns it into something you can put in front of people:
- an inner monologue that reacts to what you do
- music and sound effects
- saving your progress
- a title screen and pause
- building it and embedding it in a website
Story as events, not scripts
In a narrative game, most of the story is the main character reacting to things: entering a room, picking something up, trying a locked door. The tempting approach is to put that text wherever the thing happens in the code. Within a week, your story is scattered across dozens of functions, and nobody can read it from start to finish.
Terminal solved this with beats: the story lives in data files, one per level, and the game code only announces events. Here's a real excerpt from Terminal's hallway level:
export const LEVEL_2_MONOLOGUE = {
levelId: "level_2_hallway",
beats: [
{
id: "l2_start_01",
event: "LEVEL_START",
once: true,
lines: ["This isn't part of the station I remember."],
},
{
id: "phone_dead_01",
event: "PHONE_INTERACT_FIRST",
once: true,
lines: ["It's dead.", "Figures."],
},
],
};
The game code just says "the phone was used for the first time". The beat file decides what the character thinks about it. That separation has some lovely side effects:
- You can read the whole story in one place, in order, without reading any game code.
- Writers can edit the story without touching logic, and without being able to break it.
- Several beats can react to one event, and a beat can react to an event that isn't connected to any particular object, like walking for 16 seconds without reaching anything.
Last Train uses the same idea, slightly simplified. The game announces events like enter:hallway, pickup:keycard and flag:powerOn, and a story.js file decides what the character says.
The typewriter effect
Narrative games usually reveal text a character at a time. It's not just style: it sets a reading pace, gives the text a voice, and makes room for a small sound per character, which does a surprising amount to make silent text feel spoken.
The important usability rule: the player must always be able to skip. Press E once to show the whole line immediately, and again to move on. Nobody should be forced to wait for text they've already read.
Browser autoplay rules
Browsers won't let a page make sound until the person has interacted with it: a click, a tap or a key press. It's a good rule (nobody wants a web page shouting at them), but it catches out almost every game developer once.
Calling audio.play() before any interaction doesn't just stay silent: it returns a rejected promise, and any sound system you created early is stuck in a suspended state. The fix is to start your audio from inside the first interaction, which is one reason so many web games have a "Click to start" screen. It also solves a second problem: when a game is embedded in an iframe, it can't receive key presses until someone clicks it. One click fixes both.
What's next
In the full part 3, we build and ship the finished game:
- the monologue system and the complete Last Train story
- generated sound effects with the Web Audio API (no audio files needed) plus optional music
- saving and loading with
localStorage, including the cases where it fails - a title screen, pausing on Escape, and automatic pausing when the tab loses focus
- bundling with esbuild, and embedding the game on any site, including a Next.js site like this one
- the complete final source
Checking access…