Skip to content
Back home

Part 3 of 3 — Intermediate

Point-and-click game in plain JavaScript, part 3: monologue, sound and shipping

Give the game a voice and ship it: event-driven inner monologue, music and sound effects, saving progress, and building it for the web.
Reading time
40 min
Stack
JavaScript · Canvas 2D · Web Audio · esbuild
Published
3 November 2026

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…

More tutorials