Skip to content
Β 
Β 

Latest commit

Β 

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

404 β€” Not Found

A puzzle-platformer played entirely inside your browser's address bar and Back button. Built for the BTT Web Game Jam β€” Summer 2026 on Devpost.

Play: https://four-oh-four-three.vercel.app/

The idea

You are a small square lost inside a broken page. Escaping means realising two things the rest of the web trained you to ignore:

  1. The URL is not an address. It is you. Your position and your inventory live in window.location.hash, in plain sight, editable by hand.
  2. The Back button does not leave. It rewinds the world, and leaves an echo of your past self behind β€” sometimes the only way through.

Every run regenerates the secret key and the room geometry itself β€” the wall, the plate, the doors all sit somewhere new. There is nothing to memorise. Replay it ten times and you solve the same mechanic ten different puzzles. Difficulty ramps with each completed run, and not just by shrinking numbers: from a few replays on, room 1 grows a second wall and room 2 needs a second plate held down at the same time β€” genuinely more puzzle, not just a smaller target, and always still solvable by construction (see Every run is a new puzzle).

Visual design

Flat single-color "terminal" UI reads as unfinished, not minimal, so the palette carries meaning instead of just being decoration:

Color Means Where
Amber Default UI, structure HUD, walls, borders
Cyan (COLOR_ECHO) The rewind mechanism Room 2's plate, echoes, the rewind stat
Coral (COLOR_DANGER) Danger / secrets The seed reveal, the secret-room stat

Instructions lean on a small set of hand-drawn Canvas icons (src/render/ui.ts) instead of sentences β€” a curved arrow for the Back button, a clock for time, a key for locks β€” so an in-room hint reads as a glance, not a paragraph. The results screen uses the same icons next to each stat, and R / Esc render as physical-looking key caps rather than instructional text. The title screen itself is deliberately bare: one line of context and a single real Start button β€” nothing else is interactive, and nothing else needs to be.

Rooms glow softly (walls pulse, an open door lights up, the player has a soft halo), a faint drifting dot-field sits behind every screen for depth, and a short static-glitch flickers across the very first frame before "404" settles in. Every room transition fades through background color rather than cutting instantly.

Controls

Input Effect
← / β†’ or A / D Move
Click canvas, or any key Wakes audio + starts the game
Address bar Type coordinates or keys directly
Browser Back Rewind β€” leaves an echo at your old position
R (on the results screen) Play again β€” new key, timer and stats reset
Esc (on the results screen) Back to the title screen

On a phone

Every mechanic works on touch, not just desktop with a keyboard:

  • Move with two on-screen ←/β†’ buttons. Press-and-hold, same as an arrow key; lifting, sliding off, or an interrupted touch all release cleanly. Shown only after a real touch event has actually fired (html.is-touch, set once in main.ts) β€” not guessed from a pointer: coarse media query, which can false-positive on hybrid touchscreen laptops where a mouse is the real primary input. A desktop with only a mouse can never trigger it, full stop.
  • Play again / Menu are real, tappable buttons on the results screen, gated the same way β€” genuinely touch-only. Desktop keeps its native R / Esc keyboard shortcuts (with matching keycaps drawn right there on screen) as the one true way to do this on a keyboard; a phone with no keyboard gets its own always-visible buttons instead, not a dead end once a run finishes.
  • Address bar editing works exactly like desktop: tap it, type x= / key= values, confirm. The game deliberately never auto-hides it (overflow: hidden, no page scroll to trigger a mobile browser's scroll-away behavior).
  • Back is your browser's own back gesture β€” edge-swipe on iOS Safari, the back button/gesture on Android Chrome. Same popstate event as a desktop Back click; no special handling needed, because it was never desktop-specific to begin with.
  • A one-time nudge suggests landscape in portrait mode β€” the game is a fixed 960Γ—540 canvas, and it reads far better sideways on a phone screen. It's a hint, not a lock: portrait still fully works.

Running locally

npm install
npm run dev

Open the printed http://localhost:5173/ URL, click the canvas once, then use the arrow keys.

Verifying

npm run verify   # lint + tests + typecheck + build, in one command
Check Result
lint ESLint 9, typescript-eslint strict + stylistic β€” 0 errors
test Vitest β€” 98/98 unit tests passing
build tsc --noEmit + production vite build β€” succeeds, ~9.6 KB gzipped
dev server started for real, confirmed serving HTTP 200
real headless-browser playthrough (Playwright/Chromium) multiple full runs across every iteration, all green

The browser run drives an actual Chromium instance through the real game β€” boots, clicks, holds direction keys, presses the real Back button, edits window.location.hash the way a player editing the address bar would β€” and asserts on the resulting game state after every step: room-to-room progression, a genuinely different key each run, the results panel, R restarting cleanly, Esc returning to the title, and a best time that survives a full page reload via localStorage.

Bugs that were found and fixed by actually reading and playing the game

All of these passed npm run test the whole time β€” a symptom only shows up when a real playthrough (browser or careful manual trace) produces the exact sequence of events a player would.

1. A click alone didn't start the game. A player who types the URL and presses Enter never produces a keydown on the page β€” focus is still in the address bar. Fixed in src/core/input.ts: a pointerdown now also sets the same "a key was pressed" flag a real keydown does.

2. The Back-button echo landed in the wrong place. The original movement code committed a history checkpoint when the player stopped moving. That looks right, but it isn't: that checkpoint stays the "current" history entry through whatever move happens next, and the continuous replaceState calls during that next move silently overwrite it β€” so by the time you press Back, the position you meant to recover has already been erased. A real "stand on the plate, walk away, press Back" playthrough in Chromium exposed this immediately; a unit test testing isPlateHeld in isolation never would have. The fix, in src/game/game.ts: commit the checkpoint at the start of a new move (freezing wherever the player is resting) instead of at the end.

3. Room 3's key recovery wasn't reliably one Back press away. Same family of bug as #2, one layer deeper: if a player paused after the key was redacted and then moved again β€” completely natural while puzzling over a room β€” that fresh movement pushed a new "no key" history entry, silently burying the recoverable one an extra Back press further down. A player who missed the reveal and calmly tried "press Back" would find nothing there. Fixed with Game.keyUnsolved(): freeze-commits are suppressed entirely while a room's key is unsolved, so the redacted key stays exactly one Back press behind "current" no matter how the player moves in between.

4. The results screen told players to "try #/room/4" even after they'd just come from room 4. secretRoomVisited was already tracked but never checked before showing that hint. Fixed to show a different, accurate message once the secret room has actually been found.

5. Room 1's hints hardcoded "wall at x = 300" / "try x = 400". Since room 1 is procedurally generated, the wall is essentially never actually at x=300 β€” the tutorial hint was confidently telling players the wrong thing at exactly the moment they most needed it to be right. Fixed by making every hint read the room's actual rolled geometry instead of a fixed number; 6 new tests in hints.test.ts lock this in, including one asserting the suggested x always actually clears the wall.

6. A page reload mid-run silently desynced the puzzle. The game generates a fresh, randomly-seeded room layout on every cold boot (including a reload), but was keeping the stale position/key/echoes from the URL as if they still applied to it. They can't: the new layout has a different wall, a different plate, and a brand new key value the old key= can never match again β€” a room the player had legitimately solved would appear solved in the URL but the door would refuse to open. Fixed by respawning cleanly in the same room against the fresh layout instead of trusting stale URL state.

7. The game was entirely unplayable on a phone. An earlier version hid the canvas outright on any touch device ("this game needs a keyboard") β€” an instant zero on Fun Factor for anyone judging from a phone. Worse, even after that block was lifted, two things would have still silently broken: movement has no touch equivalent to arrow keys, and the results screen's "R to play again" / "Esc for menu" prompts are drawn as canvas pixels, not real buttons β€” a phone with no keyboard would finish one run and be stuck there permanently. Fixed with real on-screen touch buttons for movement (src/core/input.ts gained pressVirtualLeft/Right, sharing the exact same held-key set arrow keys use) and real tappable "Play again" / "Menu" buttons (Game.replay() / Game.backToMenu()) that work identically on a tap or a mouse click.

8. New players had no idea what to do for the first 14 seconds in every room. The escalating hints existed and were already short and specific ("wall at x = 300", "plate needs weight") β€” but the first one didn't appear until 14 seconds in, and the room's only immediate feedback was an atmospheric caption ("the wall does not move") that describes the obstacle without saying what to do about it. A first-time player would stare at an unexplained wall for over ten seconds with zero guidance on screen. Fixed by moving the whole escalation earlier (2s / 16s / 32s instead of 14s / 30s / 52s) β€” no new text anywhere, the exact same minimal hints just arrive while they're still useful instead of after the player has already given up looking for one.

9. The plate had no persistent label; the wall did. The wall shows its own coordinate directly on it, permanently ("x = 300") β€” genuinely excellent non-verbal teaching, since the exact number you need is right there in the game world, not just in a hint that might have scrolled past. The plate had no equivalent: just a bare outline with no indication it was even interactive, let alone named, unlike its wall counterpart. Fixed with a matching persistent label under the plate β€” "plate" while empty, "held" the moment an echo or the player actually holds it down, so the label itself confirms success instead of staying static.

10. A locked door gave no persistent signal that it was locked. The door dims when shut and glows when open β€” a good non-verbal cue β€” but nothing distinguished "closed, walk to it" from "closed, and also specifically wants a key" until the hint said so, and hints fade from memory faster than a room's actual layout does. Fixed with a small key icon above the door on any room that requires one, matching its locked/unlocked color to the door itself.

11. The intro's own instructions had a self-contradiction. The "move" row and the "back button" row both used a bare left-arrow glyph β€” a player could easily read the back-button row as "press the ← key" rather than "click your browser's actual Back button", especially right below a row that uses the identical glyph to mean exactly that. Fixed by giving the back-button row its own unambiguous BACK keycap, matching the style already used for the URL row instead of reusing a directional arrow for two different things.

12. Touch controls could false-positive onto a desktop. pointer: coarse β€” the CSS signal used to show on-screen movement buttons β€” can report true on hybrid touchscreen laptops even when a mouse is the player's actual, primary input. That would leave phantom buttons floating over a desktop game, exactly the kind of thing to embarrass a PC-only demo. Fixed by switching to real event detection instead of a hardware guess: src/main.ts now waits for an actual touchstart to fire before adding an is-touch class anywhere, and canvas.ts gained setTouchMode() so the reserved bottom margin for those buttons only ever appears for a player who has genuinely touched the screen β€” never guessed in advance from a media query that can be wrong.

13. Difficulty had no visible signal at all. The plate narrowing and the seed's reveal window shortening are both real, but both are numbers changing quietly in the background β€” nothing on screen actually told a player "this run is harder than the last one." Fixed with a small, always-visible lvl N readout and colour-coded pips during play (calm teal early, amber at the midpoint, red at the difficulty ceiling), plus a matching difficulty row in the results-screen stats panel β€” the exact colour progression asked for, not just a number.

14. The results-screen Play again / Menu buttons showed up on desktop too. They were originally gated purely on whether the stage was "found", visible on every device β€” reasonable on its own, except desktop already has a native way to do exactly this (R / Esc, with matching keycaps drawn on screen), and showing an extra pair of tappable buttons there just to duplicate a keyboard shortcut looked out of place on a platform that never needed them. Fixed by gating them behind the same real touch-detection signal as the movement buttons β€” genuinely touch-only now, not "every device."

15. The secret room's only in-game hint was too cryptic to actually act on. "The address bar counts higher than 3, too" is a nice riddle, but a riddle isn't a guide β€” a player reading it still has to guess what that even means, let alone what to type. Fixed by saying it outright: "psst β€” try typing #/room/4 up there." It still only appears after 90 seconds so it doesn't spoil the room for someone who'll find it on their own, but once it does appear, it actually tells you what to do instead of making you solve a second puzzle just to get the hint.

Every run is a new puzzle

src/game/procgen.ts regenerates the three numbered rooms from scratch at the start of every run, using a seeded PRNG (mulberry32 β€” deterministic, dependency-free). The mechanic per room never changes (room 1 is always about walls, room 2 always about plates, room 3 always a key), only the geometry does at first β€” and from a few replays on, the shape does too.

Positions are generated relative to each other with a guaranteed minimum gap rather than picked independently, so there is no random combination β€” at any difficulty β€” that produces an unsolvable room. 44 tests in procgen.test.ts check exactly that, across six different seeds and every difficulty level, including the two-wall and two-plate tiers below.

Difficulty scales with how many runs a player has completed (src/core/progress.ts, persisted like the best time):

As difficulty rises Effect
Plate width Shrinks from ~62–94px down to ~40–62px β€” the stand/away/Back sequence needs more precision
Seed reveal window Shrinks from 2.1s down to 1.3s β€” reading it live gets tighter (the Back-button recovery trick always still works, and re-arms itself the same way each time you use it)
Room 1's walls A second wall appears β€” two separate "walk into it, read the number, edit past it" moments instead of one
Room 2's plates A second plate appears, and both must be held down at once β€” two separate echoes, planned in advance, not two attempts at one

It caps at a fixed ceiling (MAX_DIFFICULTY) rather than scaling forever, so the tenth replay is harder than the first, never unfair β€” and every hint (src/game/hints.ts) reads the room's actual rolled geometry, so a hint is never wrong about how many walls or plates are actually there.

Manual play test

  1. Wait a beat β€” the URL types itself into #/boot?signal=lost then #/boot?signal=found before the "404 / NOT FOUND" square drops in.
  2. Click start. Room 1 appears.
  3. Walk right until you hit the wall β€” its position is shown on screen and in the escalating hints, never the same twice. Edit the URL's x= past it and press Enter. (From a few replays on, there's a second wall further along β€” same idea, twice.)
  4. Room 2: stand on the plate, walk away, then press the real browser Back button. An echo stays behind on the plate; the door opens because something is still holding it down. (From a few replays on, there are two plates β€” hold one down with an echo, walk to the second, and press Back again before continuing; the door needs both at once.) Hints here only appear after you've actually walked to the door and found it shut a few times β€” not on a timer, the way room 1's do.
  5. Room 3: a key flashes on screen for a couple of seconds, then the game redacts it from the URL. Press Back to recover the moment it still existed, read it, then type it into key= yourself. Recovered but didn't get to it in time? It hides again the same way β€” press Back again, it's always exactly one press behind. Same as room 2, its hints are earned by trying the door and finding it locked, not by waiting.
  6. The URL erases itself and the page becomes 200 / FOUND, with a stats panel (time, rewinds, url edits, secret room, best time).
  7. Press R to play again with a brand-new key, or Esc for the title screen. Best time persists across runs (and page reloads) via localStorage.
  8. Optional: visit #/room/4 directly β€” a hidden room, not linked from anywhere in the game. (Room 3 drops a small, late hint that it's out there, for anyone still exploring.)

How it works

  • The URL is the save file. src/core/state.ts parses and serialises the entire GameState to and from window.location.hash. Parsing never throws β€” hand-edited input is expected input, not an error.
  • The Back button is a time machine. src/core/router.ts tracks a monotonic history index to tell a genuine Back/Forward traversal apart from a manual URL edit. src/game/game.ts uses that distinction to spawn an "echo" on rewind.
  • The rooms are regenerated every run, not just the key (src/game/procgen.ts) β€” see Every run is a new puzzle above.
  • The secret key is regenerated every run (src/game/key.ts) from a 4-character alphabet with no look-alike characters (no 0/O, 1/l), so it stays readable under a short time limit. The room definitions only know whether a door needs a key, never the value β€” that value is a private field on Game, not part of the shareable URL state.
  • Doors, plates, and walls are all derived, never stored (src/game/rules.ts), so the rendered game can never disagree with what is written in the address bar.
  • All audio is synthesised at runtime (src/audio/engine.ts) β€” the shipped bundle contains zero audio files. All visuals are Canvas 2D (src/render/) β€” zero image files. The whole game is ~27 KB (~9 KB gzipped).

Project structure

src/
β”œβ”€β”€ audio/engine.ts     procedural sound (Web Audio API, no audio files)
β”œβ”€β”€ config/
β”‚   β”œβ”€β”€ constants.ts     tunable numbers: speed, resolution, key alphabet
β”‚   └── rooms.ts          base room templates (room 4, the secret room)
β”œβ”€β”€ core/
β”‚   β”œβ”€β”€ input.ts          keyboard + click handling
β”‚   β”œβ”€β”€ loop.ts            requestAnimationFrame driver
β”‚   β”œβ”€β”€ progress.ts        run-count persistence (drives difficulty)
β”‚   β”œβ”€β”€ router.ts          window.history <-> GameState, rewind detection
β”‚   β”œβ”€β”€ score.ts           best-time persistence (localStorage, defensive)
β”‚   β”œβ”€β”€ state.ts           GameState <-> location.hash (parse/serialize)
β”‚   └── types.ts           shared types
β”œβ”€β”€ game/
β”‚   β”œβ”€β”€ game.ts            state machine + run lifecycle (replay, stats)
β”‚   β”œβ”€β”€ hints.ts            time-based escalating hints per room
β”‚   β”œβ”€β”€ key.ts              random per-run secret key generator
β”‚   β”œβ”€β”€ procgen.ts          per-run randomized room layouts + difficulty
β”‚   └── rules.ts            pure collision / door / plate logic
β”œβ”€β”€ render/
β”‚   β”œβ”€β”€ canvas.ts           DPR-aware canvas setup
β”‚   β”œβ”€β”€ render.ts           per-stage drawing incl. results panel, HUD
β”‚   └── ui.ts                canvas drawing primitives (text, icons, glow)
└── main.ts               wires it all together, hosts the intro overlay

Deploying

Live on Vercel: https://four-oh-four-three.vercel.app/ β€” connected directly to this repo's main branch, so every push rebuilds and redeploys automatically. No custom config needed: Vercel auto-detects the Vite build (npm run build β†’ dist/).

npm run build   # outputs to dist/ β€” Vercel does this itself on push

vite.config.ts uses a relative base: "./", so dist/ is portable as-is to Vercel, Netlify, GitHub Pages, or itch.io if you'd rather host it elsewhere. On Vercel specifically: import the GitHub repo, framework preset Vite (auto-detected), no build settings need changing.

Tech stack

TypeScript (strict), Vite 8, Vitest 4, ESLint 9 with typescript-eslint strict + stylistic type-checked rules, Prettier. No runtime dependencies β€” the production bundle is 100% first-party code.

License

MIT β€” see LICENSE.

About

Stateloop publishing fork of michaelfrancoodev/four-oh-four. Original game and MIT license by its upstream authors.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages