I built Terminal, a narrative game set in an abandoned London Underground station, with no game engine: just JavaScript, a canvas and a lot of trial and error. It was one of the most enjoyable projects I've worked on, and also one of the most educational, because every system I'd normally get from an engine, I had to build and get wrong first.
This three-part series is the version I wish I'd started with. Together we'll build Last Train, a short point-and-click game about finding your way out of a deserted station:
- Part 1 (this one): the canvas, a game loop, keyboard input, a scrolling camera and rooms
- Part 2: hotspots, an inventory, story flags and puzzles
- Part 3: inner monologue, sound, saving, and shipping it to the web
Every part ends with a playable game. There are no image or sound files to download: everything is drawn with code, so you can add your own art later.
Why no engine?
Engines like Phaser, Godot and Unity are excellent, and for a big game you should use one. But for a small narrative game, going without one has real advantages:
- You understand everything. When something breaks, there's no engine code between you and the bug.
- It's tiny. The finished game is a few kilobytes of JavaScript. It loads instantly and embeds anywhere.
- It teaches you how engines work. Game loops, input, cameras and state are the same ideas in every engine. Build them once and every engine makes more sense.
The shape of a narrative game
Before writing code, it helps to see how little a game like this actually needs:
- A game loop that updates the world and draws it, many times a second
- Input: which keys are held, and which were just pressed
- A player who walks left and right
- Rooms that are wider than the screen, with a camera that follows the player
- Exits that move you between rooms
That's part 1. Everything that makes it a game (items, puzzles, story) sits on top of these.
Project setup
Create this structure:
last-train/
index.html
style.css
src/
main.js
input.js
rooms.js
world.js
draw.js
index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Last Train</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<canvas
id="game"
width="960"
height="540"
tabindex="0"
aria-label="Last Train. Use the arrow keys or A and D to walk, and E to interact."
></canvas>
<script type="module" src="src/main.js"></script>
</body>
</html>
A few details here matter more than they look:
width="960" height="540"sets the canvas's internal resolution: the size of the drawing surface we draw on. Every coordinate in the game uses this 960 × 540 space, whatever size the window is.tabindex="0"makes the canvas focusable, so it can receive keyboard events and show a focus outline.aria-labelgives screen reader users a description and the controls. Canvas content is invisible to assistive technology, so this label is the only information they get.type="module"lets us split the game into files withimportandexport, with no build step.
style.css
html,
body {
margin: 0;
height: 100%;
background: #000;
}
body {
display: grid;
place-items: center;
}
canvas {
width: min(100vw, calc(100vh * 16 / 9));
aspect-ratio: 16 / 9;
outline: none;
}
The CSS scales the canvas to fit the window while keeping its 16:9 shape. The browser stretches our 960 × 540 drawing to whatever size it's displayed at. This is the simplest way to support every screen size: we design for one resolution, and CSS handles the rest.
Running it locally
ES modules don't work if you open index.html directly from your file system (as a file:// URL); the browser blocks them for security reasons. Serve the folder with any local web server instead:
cd last-train
python3 -m http.server 8000
# or
npx serve .
Then open http://localhost:8000.
Input: held keys and pressed keys
Games need two different questions answered about the keyboard:
- Is this key held down right now? For walking: you walk for as long as you hold the arrow key.
- Was this key just pressed? For actions: one press of E should interact once, not 60 times a second.
Here's the whole input module:
// src/input.js
const KEYMAP = {
ArrowLeft: 'left',
KeyA: 'left',
ArrowRight: 'right',
KeyD: 'right',
KeyE: 'interact',
Enter: 'interact',
Space: 'interact',
Escape: 'pause',
KeyP: 'pause',
};
const held = new Set();
const pressed = new Set();
window.addEventListener('keydown', (e) => {
const action = KEYMAP[e.code];
if (!action) return;
e.preventDefault();
held.add(e.code);
if (!e.repeat) pressed.add(action);
});
window.addEventListener('keyup', (e) => {
held.delete(e.code);
});
window.addEventListener('blur', () => held.clear());
export const input = {
isDown: (action) => [...held].some((code) => KEYMAP[code] === action),
wasPressed: (action) => pressed.has(action),
endStep: () => pressed.clear(),
};
Four decisions in there are worth understanding, because each one prevents a bug that's easy to ship:
e.code, note.key.e.codeis the physical key position. On a French AZERTY keyboard, the key in the "W" position types "Z", but itscodeis stillKeyW. Usingcodemeans WASD-style controls work on every keyboard layout.- Actions, not keys. The game asks "is
leftdown?", never "isArrowLeftdown?". TheKEYMAPis the only place that knows about physical keys, which makes rebinding controls easy later. e.repeatis ignored for presses. When you hold a key, the operating system sends repeatedkeydownevents. Without the check, holding E would interact over and over.- Clear held keys on
blur. If you hold an arrow key and switch to another window, the game never receives thekeyup. When you come back, the player is stuck walking forever. Terminal clears its keys onblurfor exactly this reason.
We store held keys by code rather than action, so holding both A and the left arrow, then releasing one, doesn't stop you walking.
What's next
You have a page, a canvas and input that behaves. In the full part 1 we bring it to life:
- a game loop that runs at the same speed on a 60Hz laptop and a 144Hz gaming monitor
- a player who walks, faces the right way and bobs as they move
- rooms wider than the screen, with a smooth camera that stops at the edges
- exits between rooms, with fade-to-black transitions
- the complete, playable part 1 source
Checking access…