Lost, not stranded: how we built a playful 404 page

November 2, 2026

Everyone has clicked a link and landed on a page that says “404”. It means the website could not find what you asked for. Maybe the page moved, maybe the link had a typo, maybe it never existed at all.

Most 404 pages are a shrug in web form. When Dan planned the DRS Web 404 page, the aim was for ours to do two jobs instead: get you back on track quickly, and, if you have a spare minute, give you something small and fun to play with.

This article is the first in a short Learn series. Here we look at the page itself and the little loader that picks a game. The next four articles take one game each, with real code and plain-English explanations.

Why bother with a playful 404?

A broken link is a tiny moment of friction. On a business website, that friction can be the difference between someone staying and someone closing the tab.

A playful 404 does a few useful things:

  • It changes the mood. “Oops, wrong turn” feels a lot better when the page has a sense of humour.
  • It shows the craft. For a web developer’s own site, the 404 page is a sneaky little portfolio piece.
  • It buys a second chance. Someone who smiles is more likely to try the search box than someone who feels lost.

But fun must never get in the way. So the rule for this page was simple: recovery first, games second.

Recovery first

If you land on the DRS Web 404 page, here is what you see, top to bottom:

  1. The normal site header, with the full menu. You are still on the DRS Web site, and every main page is one tap away.
  2. A clear heading that says this is a 404, plus a short, friendly explanation.
  3. A search box, so you can look for what you meant to find.
  4. A short list of popular pages: Home, Learn, Services and Contact.
  5. A big “go to homepage” button.
  6. Only then, the game panel, under the heading “Fancy a quick game while you are here?”
  7. The normal site footer.

The popular links are a plain list in the page template, so they work even if JavaScript is switched off or fails to load:

$drs_popular_links = array(
	'/'            => __( 'Home', 'drs-web' ),
	'/learn/'      => __( 'Learn', 'drs-web' ),
	'/services/'   => __( 'Services', 'drs-web' ),
	'/contact-us/' => __( 'Contact', 'drs-web' ),
);

That is a simple PHP list of web addresses and the words to show for each one. The template loops over it and prints a normal link for each entry, inside a nav element labelled “Popular pages” so screen reader users can jump straight to it.

The games quietly point you home too. In Reconnect the link, solving the puzzle lights up the homepage button. In Catch the missing page, catching the page reveals a “Back to the homepage” link. In Type the URL, the word you unscramble is a real address on the site, and it turns into a working link. The fun and the way out are the same thing.

One random game, loaded only when needed

There are four games, but you only ever download one at a time. This matters because most visitors to any website never see the 404 page at all, and those who do should not have to wait for things they will not use.

Step 1: only load the loader on the 404 page

WordPress lets a theme decide which scripts go on which pages. This function adds the game loader on the 404 page and nowhere else:

function drs_404_games_assets() {
	if ( ! is_404() ) {
		return;
	}

	wp_enqueue_script( 'drs-404-games', get_stylesheet_directory_uri() . '/js/games/loader.min.js', array(), drs_404_games_version(), array( 'strategy' => 'defer', 'in_footer' => true ) );
}

In plain English: “If this is not the 404 page, do nothing. Otherwise, add the loader script at the bottom of the page, and let the browser run it after the page content is ready.” The defer strategy means the script never holds up the heading, the search box or the links.

Step 2: pick a game

The loader keeps a short list of the games it knows about:

const GAMES = [
  { id: 'blob-pop', title: 'Blob pop' },
  { id: 'reconnect', title: 'Reconnect the link' },
  { id: 'catch-page', title: 'Catch the missing page' },
  { id: 'type-url', title: 'Type the URL' }
];

Then it picks one at random, making sure “Try another game” never hands you the one you just played:

function pickGame(excludeId) {
  const choices = GAMES.filter(function (game) {
    return game.id !== excludeId;
  });
  const pool = choices.length > 0 ? choices : GAMES;
  return pool[Math.floor(Math.random() * pool.length)];
}

filter builds a new list without the current game. Math.random() gives a number between 0 and 1, which is multiplied by the number of choices and rounded down to get a position in the list.

Step 3: fetch just that one file

Each game lives in its own file. The loader adds a script tag for the chosen game and waits for it to arrive:

function loadGameScript(game) {
  if (!scriptRequests[game.id]) {
    scriptRequests[game.id] = new Promise(function (resolve, reject) {
      const script = document.createElement('script');
      script.src = gameScriptUrl(game);
      script.async = true;
      // ...
      script.onerror = function () {
        delete scriptRequests[game.id];
        reject(new Error('Game failed to load: ' + game.id));
      };
      document.head.appendChild(script);
    });
  }
  return scriptRequests[game.id];
}

Two small details here are worth pointing out:

  • Each game is only requested once. The loader remembers the request in scriptRequests. If you cycle back to a game you have already played, it reuses the file instead of downloading it again.
  • A failed download is forgotten. If the network drops, the loader deletes its note about that game, so the next attempt can try again from scratch. The visitor sees “Sorry, that game could not load.” instead of a broken box.

Step 4: a tiny contract between loader and games

Every game file ends the same way. It registers itself on a shared object with two functions:

window.drsGames = window.drsGames || {};
window.drsGames['blob-pop'] = { mount: mount, destroy: destroy };

mount draws the game into the page. destroy tidies it away: it stops timers and animation loops and removes the game from the page. Because every game follows this agreement, the loader does not need to know how any of them work inside. Adding a fifth game later means writing one new file and adding one line to the GAMES list.

When the loader starts a game, it also tells it whether the visitor has asked their device for reduced motion:

instance.mount(stage, { reducedMotion: prefersReducedMotion() });

Each game then decides how to calm itself down. We cover what that means for each one in its own article.

Step 5: never serve a stale game

Browsers keep copies of files to save time, which is usually great. But if a game is updated, you want visitors to get the new version. The theme works out a version number from the date the newest game file was last changed:

function drs_404_games_version() {
	$files = glob( get_stylesheet_directory() . '/js/games/*.min.js' );
	return $files ? (string) max( array_map( 'filemtime', $files ) ) : wp_get_theme()->get( 'Version' );
}

That number is added to the end of each game’s web address. When any game file changes, the number changes, and browsers fetch the fresh copy.

How small is “small”?

The goal was for each game to stay under 5KB once minified (minifying strips out spaces and shortens names, without changing what the code does). Here are the real file sizes, measured from the theme:

File Minified size Gzip compressed
Loader 1,362 bytes 740 bytes
Blob pop 4,506 bytes 2,024 bytes
Reconnect the link 4,975 bytes 2,188 bytes
Catch the missing page 4,004 bytes 1,796 bytes
Type the URL 3,945 bytes 1,793 bytes

So a visit to the 404 page downloads the loader plus one game: at most 6,337 bytes before compression. For comparison, a single small photo on a web page is often many times that.

No game uses a library or framework. They are plain JavaScript, built from normal page elements such as buttons, so the browser does most of the heavy lifting.

Accessibility, built in from the start

A game on a 404 page is a bonus, so it must never become a barrier. A few things apply to all four games:

  • Real buttons. Tiles, spots and controls are button elements, so they work with a keyboard, a switch device or a screen reader without extra tricks.
  • Instructions on screen. Each game shows a short help line, linked to the play area with aria-describedby, so assistive technology reads it out too.
  • Spoken updates. Scores, hints and results go into a “live region” (aria-live="polite"), which screen readers announce when there is a natural pause. The loader has one as well, announcing “Now playing: Blob pop” or whichever game was picked.
  • Keyboard play everywhere. Every game can be played without a mouse or touch screen. The keys differ per game, and each article explains them.
  • Reduced motion respected. If your device is set to reduce motion, every game changes how it behaves, not just how fast.
  • A fallback without JavaScript. A noscript message explains that the games need JavaScript, but the links above still work.
  • Pausing when you look away. The two games that move on their own, Blob pop and Catch the missing page, pause when you switch to another tab, so nothing runs in the background.

Where coding meets creativity

The fun part of this project was finding ways to make each game about being lost on a website. You pop blobs while the page is missing. You rebuild a broken cable from “404” to “Home”. You chase a runaway page back to its shelf. You unscramble a real address. Each idea is a small joke, and each one ends with a way out.

The code side was about keeping that playfulness light: one loader, four tiny modules, no frameworks, and the boring but important bits (search, links, keyboard support) done properly first.

The rest of the series

  • Blob pop: a 20-second game built with requestAnimationFrame
  • Reconnect the link: a tile puzzle that is always solvable
  • Catch the missing page: smooth movement with the FLIP technique
  • Type the URL: a word puzzle that turns into a real link

And if you ever find yourself on our 404 page, there is a link back to this series waiting for you there.

Comments are closed.