Keyboard, touch, and click-to-travel stay predictable when they share one collision-aware simulation and one verification path.
A learning game has two systems that can drift apart: player movement and lesson delivery.
I saw that gap while rebuilding a browser prototype. The first version tied the traveler to a scroll position. The new version needed free movement, discoverable landmarks, animated stories, questions, rewards, touch controls, and saves. I routed every input through one movement model. The story layer could then pause that model instead of recreating movement rules.
route every input through one simulation
The keyboard, touch joystick, and click-to-travel controls produce different input events. They do not need different movement code.
Each frame, the game combines the active inputs into a direction vector. It normalizes that vector, selects a walking or running speed, and gives the resulting delta to the navigation module:
let dx =
(this.keys.has("d") || this.keys.has("arrowright") ? 1 : 0) -
(this.keys.has("a") || this.keys.has("arrowleft") ? 1 : 0) +
this.stick.x,
dy =
(this.keys.has("s") || this.keys.has("arrowdown") ? 1 : 0) -
(this.keys.has("w") || this.keys.has("arrowup") ? 1 : 0) +
this.stick.y;
let len = Math.hypot(dx, dy);
const running = this.keys.has("shift") || this.touchRun;
const speed = running ? 195 : 116;
let step = speed * dt;
if (len > 0.08) {
dx /= Math.max(1, len);
dy /= Math.max(1, len);
this.island.nav.move(p, dx * step, dy * step);
}
This prevents diagonal input from increasing the speed. Because every mode reaches
this.island.nav.move, they share collision behavior, proximity checks, animation state, footsteps,
and save timing.
When a story dialog is open, the game loop skips the movement update. The controller also clears active input when the window loses focus or the document becomes hidden. The dialog does not need to know how the player was moving. It changes the state that the loop already checks.
separate free walking from guided travel
Free walking and click-to-travel choose a destination in different ways. They still use one movement system.
The navigation module builds a 20-pixel grid from the island polygon and obstacle list. A cell is walkable only when the player footprint stays inside the coast and outside nearby obstacles. The module indexes obstacles in 80-pixel buckets, so each walkability check does not scan every prop.
Direct movement uses small substeps. If a requested frame delta is longer than four pixels, the module splits it before checking the x and y axes. This prevents a fast frame from crossing a building or shoreline in one jump.
Click-to-travel uses the same walkability grid. It searches the eight neighboring cells with a breadth-first search (BFS), rejects diagonal moves that cut across blocked corners, and caches the line checks between adjacent cells. The game follows the returned points until directional input arrives. If a collision interrupts the route, the route is cleared and the player can choose a new direction.
Paths can guide a first visit, but grass stays walkable and direct input always takes priority.
keep chapter content in data
The engine does not contain the chapter's historical text. An episode JSON file defines the island, landmarks, story beats, questions, relics, guides, and reading links. The engine reads that data and renders the same interaction model for each landmark.
The chapter has eight landmarks. Each landmark has three story beats and one three-option challenge. A correct answer grants a seal; a wrong answer shows an explanation and leaves the seal locked. The journal and atlas read the same completion state, so replay and fast travel do not need separate progress rules.
The static verifier checks this boundary. It checks that all landmarks and off-path objects are reachable, that each challenge has a valid answer, that generated SVG groups are balanced, and that the runtime modules do not contain chapter-specific names. A content change can therefore fail without changing engine code.
pause and persist at lifecycle boundaries
The browser can interrupt a game without asking the game first. A tab can become hidden. Storage
can contain malformed JSON. Private browsing or a browser policy can make localStorage throw.
The save layer treats stored data as untrusted input. It requires a version, keeps only known identifiers, validates the position and settings, and returns a fresh save when parsing fails. The write path catches storage errors and keeps the current visit playable in memory.
A five-second interval writes progress during play. The game also writes on page hide and after a player action, such as starting a journey or discovering a landmark. The pause screen reports whether progress is saved on the device or lasts only for the current visit. That message describes the actual storage boundary.
verify the complete learning loop
A unit test can prove that a route exists. It cannot prove that a player can reach a landmark, open its story, answer a question, reload, and continue.
The final check used two layers:
- A static verifier checked 39 local assets, eight landmarks, 24 story beats, eight challenges, five relics, routing, collision, SVG structure, pixel rendering, and the absence of external runtime requests.
- Browser suites exercised the first learning loop, all eight landmarks and the ending, all five relics, a 390×844 touch context, and corrupt or unavailable storage.
The browser run caught issues that the static checks could not see. An SVG animation transform was applied at the wrong nesting level. Escape opened and closed a pause dialog in the same event. The mobile toolbar overflowed, and an informational footer intercepted clicks on the ground. I followed each fix with the affected browser flow alongside the source-level checks.
The final build reported BROWSER VERIFY PASS across all five suites and SELFTEST PASS — 12/12
in the page. It also reported no console errors and no external runtime requests.
use one state boundary for the whole experience
The important boundary is between input, simulation, content, and verification, not a particular canvas API or routing algorithm.
With one collision-aware movement model, the game can add a joystick or click-to-travel without changing how stories open. With chapter content in data, the engine can grow without copying historical text into control code. Explicit state boundaries keep dialogs, focus changes, and storage failures from breaking the learning loop.
That structure made the move from a scroll demo to an explorable chapter manageable. It also made the player experience testable, with function-level checks as one part of the evidence.