Pokémon Browser Game: Move one step
Outcome
Section titled “Outcome”Move the trainer one square at a time with North, South, West, and East buttons. Reject destinations outside the map or on blocked terrain. Keep the sprite, position summary, status message, and keyboard focus in agreement with the stored position. Entering the Pokémon Center restores the starter’s hit points.
What you will practice
- Calculate a proposed row and column before changing state.
- Use the existing coordinate lookup to distinguish an absent tile from blocked terrain.
- Update the position only for an accepted move, then render the complete result.
- Use native buttons for pointer, Enter, and Space activation.
- Keep movement unavailable before trainer setup and during an active encounter.
- Test movement, rejection, healing, status, and focus together.
Why this stage matters
Section titled “Why this stage matters”The previous stage made the map and summary respond to state.mapPosition. Movement changes that one value. Each button follows this route:
button activation → proposed coordinate → destination check → state and status result → renderGame()A rejected move keeps the old coordinate, sets a reason, and renders the result. This stage has no encounter roll or battle action.
What is new and what is reused
Section titled “What is new and what is reused”- New in this Guided Build: Four direction controls, one-square coordinate changes, outside-map and blocked results, Center healing, and movement availability derived from state.
- Reused from Module 2: Conditions choose the result. One independent button action connects a native control. Coordinate lookup finds the destination. The event → state → render cycle updates the interface. Earlier project articles used these patterns; review the linked sections when you adapt them here.
- Reused from earlier project stages: The fixed
mapTilesarray,findMapTile(),findTerrainDefinition(),state.mapPosition,renderGame(), the status region, and the starter’s current and maximum hit points.
A native button’s disabled HTML attribute stops pointer and keyboard activation. This is a Boolean attribute: its presence means the button is disabled. Read Keep an independent button unavailable until ready for the attribute and the setAttribute() and removeAttribute() pattern. Keep a guard in the movement function too, so a direct call cannot change the position before setup or during an encounter.
Starting point
Before you start
- The trainer-position article passes its complete self-check and has a passing Git checkpoint.
- game.html has a trainer form, overworld map, and current-game summary with a polite status region.
- game.js renders one trainer marker from state.mapPosition and starts a valid trainer at row 2, column 2.
- findMapTile(row, column) returns a map record or null; blocked terrain uses the ID blocked.
- A valid starter has currentHitPoints and maximumHitPoints.
- Current state
- A temporary Console change moves the sprite and summary. No page control moves the trainer yet.
- First action
- In game.html, add a labeled movement group after the map list with one North button: type=button, a stable ID, and disabled.
- First checkpoint
- Before setup, the map still has 25 tiles and the visible North button is unavailable.
- Help trigger
- Open the nearest Assistance level or ask for help if one activation moves more than one square, a rejection changes the position, the marker and summary disagree, a disabled control moves the trainer, the Center does not heal, or focus disappears.
Required result
Section titled “Required result”Complete this stage when:
game.htmlhas four labeled native buttons withtype="button"in North, South, West, East source order;- all four are disabled before setup and while
state.activeEncounterhas a value; - valid setup enables them and moves focus to North, the first movement control;
- each activation proposes exactly one adjacent square in the named direction;
- outside-map and blocked proposals keep
state.mapPositionunchanged and report distinct reasons; - an accepted proposal stores its row and column before
renderGame()updates the marker, location text, and status; - entering the Center restores the starter’s current hit points to the maximum and reports whether healing changed the value;
- the activated button retains focus after an accepted or rejected move, and the status region does not take focus;
- pointer, Enter, and Space each produce one result per activation; and
- the map, controls, summary, and focus work near 320 CSS pixels and at 200% browser zoom.
Keep encounter rolls, battle controls, arrow-key movement, animation, and a second stored position out of this stage.
1. Add one native direction control
Section titled “1. Add one native direction control”In game.html, add a movement group after the existing empty overworld list and before Current game. Give it a visible Move one step heading and an instruction to start a trainer before choosing a direction. Add a North button with type="button", a stable ID, and disabled in saved HTML. Put it outside the trainer form.
The initial disabled attribute matches the initial null position even if JavaScript does not run. JavaScript will later update the attribute from state. Keep the saved map list empty; renderMap() still creates its tiles.
In styles.css, prepare a wrapping row with the existing spacing tokens. Give movement buttons a minimum height like the trainer form button, a visible focus outline, and a distinct disabled appearance. Reuse Module 1’s state-selector pattern for :focus-visible and :disabled. The button text must remain readable.
Reload game.html. Before setup, the map has 25 tiles and no marker. North is visible but cannot be activated or reached with Tab. Enter in the trainer-name field still submits the trainer form.
If it does not work
Section titled “If it does not work”If North submits the form, inspect its type and its place relative to the closing </form> tag.
Checkpoint: The first control has a stable place
- What now works
- North is a native action outside the form and is unavailable before setup. The existing map and form still work.
- Files changed
game.html, styles.css- What remains
- Connect North to one accepted or rejected movement result.
- Next action
- Select North in game.js and include it in the missing-elements guard.
- If it does not work
- Inspect the saved button's ID, type, disabled attribute, and position outside the form.
2. Propose and check one destination
Section titled “2. Propose and check one destination”Select North once beside the other stable DOM elements in game.js. Add it to the missing-elements guard. Define a movement function that receives a direction name, row change, and column change. North uses row change -1 and column change 0.
Before reading state.mapPosition.row, check that a trainer position exists and state.activeEncounter is null. Calculate two local values without changing state:
const proposedRow = state.mapPosition.row + rowChange;const proposedColumn = state.mapPosition.column + columnChange;Pass both numbers to findMapTile(). Use the returned record to choose one result:
| Lookup result | Position result | Status meaning |
|---|---|---|
null |
Keep the old coordinate | Outside the map |
A record with terrain "blocked" |
Keep the old coordinate | Blocked terrain |
| Another record | Store the proposed coordinate | Moved one square |
Set state.statusMessage for each result. Call renderGame() after the result is complete. For an accepted move, use findTerrainDefinition() to name the destination terrain in the status. A move to a path or grass tile does not change hit points.
Register a North click listener once, outside renderGame(). Follow the independent button pattern: pass a function reference to addEventListener(). The native button sends a click event for pointer, Enter, and Space activation.
At the end of renderGame(), remove North’s disabled attribute only when a trainer position exists and no encounter is active. Otherwise set the attribute. After valid setup calls renderGame(), focus North. The map is replaced during rendering; the button is not, so movement does not need to force focus after each activation.
Start a valid trainer. North is enabled and focused. Activate it once: the JavaScript position becomes { row: 1, column: 2 }, and the page reports Tall grass — row 2, column 3. Activate North again to reach { row: 0, column: 2 }. A third North activation reports outside the map and keeps that coordinate.

If it does not work
Section titled “If it does not work”- If one click changes more than one row, check that the listener is registered once outside
renderGame(). - If the map-edge attempt moves the sprite, check for
nullbefore assigning tostate.mapPosition. - If state changes but the marker does not, check that the accepted branch calls
renderGame()after storing both coordinates.
Assistance 1 — Trace North as three decisions
Write the current coordinate, proposed coordinate, and lookup result for each of the three North activations from the Center. Only a record that exists and is not blocked can become the new position.
Assistance 2 — Keep the old coordinate until validation passes
Put the outside-map and blocked checks before the state assignment. Each rejection sets a specific status, renders, and returns. Only the remaining branch stores the proposed coordinate.
Checkpoint: North moves one square and respects the map edge
- What now works
- North moves one tile at a time, rejects the north edge, and keeps the same button focused.
- Files changed
game.js- What remains
- Apply the same contract to the other directions, blocked terrain, and Center healing.
- Next action
- Add South, West, and East with their one-square coordinate changes.
- If it does not work
- Inspect the proposed row, proposed column, and lookup result before changing the renderer.
3. Apply the contract to all directions
Section titled “3. Apply the contract to all directions”Add South, West, and East beside North in game.html. Start each button disabled. Give each a stable ID and type="button". Select them in game.js, include them in the guard, and update all four from the same movement-availability condition. Register each listener once outside rendering. Each listener calls the same movement function with different changes:
| Direction | Row change | Column change |
|---|---|---|
| North | -1 |
0 |
| South | +1 |
0 |
| West | 0 |
-1 |
| East | 0 |
+1 |
Keep edge and blocked buttons enabled during exploration. The player can then attempt the direction and receive the relevant rejection message.
For a blocked test, start a fresh trainer. Move North twice to row 0, column 2, then West to row 0, column 1. A second West proposes row 0, column 0, an X tile. The trainer stays at row 0, column 1. The fixed map definition does not change.

Test all four directions from fresh setup. Each accepted action changes exactly one coordinate by one, and the activated button keeps focus. Test the blocked route and an outer edge. A rejection preserves the previous coordinate and one marker while the status names the reason.
If it does not work
Section titled “If it does not work”If West changes the row, compare its listener arguments with the direction table. If the trainer enters an X tile, inspect the returned destination’s terrain before the state assignment.
Checkpoint: All directions share the movement contract
- What now works
- Four controls move one square at a time. Outside-map and blocked attempts preserve the last valid coordinate and report distinct reasons.
- Files changed
game.html, game.js, styles.css- What remains
- Restore hit points when an accepted move enters the Center.
- Next action
- Check the accepted destination terrain before setting its final status message.
- If it does not work
- For the failed direction, record the proposed coordinate, returned tile, and stored coordinate after activation.
4. Heal on an accepted Center entry
Section titled “4. Heal on an accepted Center entry”After storing an accepted destination, check whether its terrain is "center". If so, compare the starter’s currentHitPoints with maximumHitPoints. Set current hit points to the maximum and report whether the value changed. Do this before renderGame() so the hit-point summary and status show the same completed action.
There is no battle yet, so the normal starter starts at full health. Use this temporary Console setup after valid trainer setup:
state.starter.currentHitPoints = 17;renderGame();Move East once to the path, then West back to the Center. The starter returns to 40 hit points and the status reports healing. Repeat East and West at full health; the status now reports that the starter was already full. Reload to clear the Console change. Do not save it in game.js.
If it does not work
Section titled “If it does not work”If hit points change on the path, put healing inside the accepted Center condition. If the number heals but the status says it was already full, compare the hit-point values before assigning the maximum.
Optional assistance for the complete change
Section titled “Optional assistance for the complete change”Assistance 3 — Plan the movement function in subgoals
- Return without changing position when no trainer exists or an encounter is active.
- Calculate the proposed row and column, then look up that tile.
- Give absent and blocked tiles separate status results, renders, and returns.
- Store the valid coordinate. If it is the Center, decide whether healing applies. Set one accepted status and render.
- Keep button selection and listener registration outside rendering. Update only the buttons’ disabled attributes inside rendering.
Assistance 4 — Use a partial movement structure
Fill in the missing decisions and status messages in this partial function:
function moveTrainer(direction, rowChange, columnChange) { // Check setup and activeEncounter before reading mapPosition. const proposedRow = state.mapPosition.row + rowChange; const proposedColumn = state.mapPosition.column + columnChange; const destination = findMapTile(proposedRow, proposedColumn);
// Handle null, then blocked terrain. Each rejection renders and returns. state.mapPosition = { row: proposedRow, column: proposedColumn }; // Decide whether entering the Center heals the starter. // Set one accepted status and render.}Connect North first. Test it before connecting three more listeners.
Assistance 5 — Review the complete movement changesExample solution
Keep the map, terrain, trainer, and rendering code from the previous article. The movement group belongs after the map list in game.html:
<section class="movement" aria-labelledby="movement-heading"> <h3 id="movement-heading">Move one step</h3> <p>Start a trainer, then choose one direction for each step.</p> <div class="movement-controls"> <button id="move-north" type="button" disabled>North</button> <button id="move-south" type="button" disabled>South</button> <button id="move-west" type="button" disabled>West</button> <button id="move-east" type="button" disabled>East</button> </div></section>Select all four buttons beside overworldMap. Add them to the existing missing-elements guard, then put them in an array named movementButtons:
const northButton = document.querySelector("#move-north");const southButton = document.querySelector("#move-south");const westButton = document.querySelector("#move-west");const eastButton = document.querySelector("#move-east");
// Add all four buttons to the existing missing-elements condition.const movementButtons = [northButton, southButton, westButton, eastButton];The array belongs after the guard, where all four selections are known to exist. Add this function beside the trainer handler:
function moveTrainer(direction, rowChange, columnChange) { if (state.mapPosition === null || state.activeEncounter !== null) { return; }
const proposedRow = state.mapPosition.row + rowChange; const proposedColumn = state.mapPosition.column + columnChange; const destination = findMapTile(proposedRow, proposedColumn);
if (destination === null) { state.statusMessage = `Cannot move ${direction}: outside the map.`; renderGame(); return; }
if (destination.terrain === "blocked") { state.statusMessage = `Cannot move ${direction}: blocked terrain.`; renderGame(); return; }
state.mapPosition = { row: proposedRow, column: proposedColumn }; const terrain = findTerrainDefinition(destination.terrain);
if (terrain === null) { throw new Error(`Unknown terrain: ${destination.terrain}`); }
if (destination.terrain === "center") { const neededHealing = state.starter.currentHitPoints < state.starter.maximumHitPoints; state.starter.currentHitPoints = state.starter.maximumHitPoints; if (neededHealing) { state.statusMessage = `${state.starter.name} was healed at the Pokémon Center.`; } else { state.statusMessage = `${state.starter.name} is already at full hit points.`; } } else { state.statusMessage = `Moved ${direction} to ${terrain.name} (row ${proposedRow + 1}, column ${proposedColumn + 1}).`; }
renderGame();}At the end of renderGame(), after renderMap(), update the four stable buttons:
const canMove = state.mapPosition !== null && state.activeEncounter === null;
for (const movementButton of movementButtons) { if (canMove) { movementButton.removeAttribute("disabled"); } else { movementButton.setAttribute("disabled", ""); }}After valid trainer setup calls renderGame(), call northButton.focus(). Define four named click handlers and register each one beside the trainer-form listener:
function handleNorthClick() { moveTrainer("north", -1, 0); }function handleSouthClick() { moveTrainer("south", 1, 0); }function handleWestClick() { moveTrainer("west", 0, -1); }function handleEastClick() { moveTrainer("east", 0, 1); }
trainerForm.addEventListener("submit", handleTrainerSubmit);northButton.addEventListener("click", handleNorthClick);southButton.addEventListener("click", handleSouthClick);westButton.addEventListener("click", handleWestClick);eastButton.addEventListener("click", handleEastClick);Keep the existing final renderGame() call. Style .movement-controls as a wrapping row with a gap, minimum-height buttons, visible focus, and a distinct disabled appearance. Use the existing project tokens and trainer-button styles as the starting point.
Verify the stage
Section titled “Verify the stage”Start each independent route with a fresh reload. After each action, compare state.mapPosition, the marked tile, summary, status, focus, and Console.
| Test | Action | Expected result |
|---|---|---|
| Before setup | Reload; use Tab | Four disabled buttons, no marker, Not set., and setup status |
| Valid setup | Submit a fictional name and starter | Center at JavaScript row 2, column 2; four enabled buttons; North focused |
| Four directions | Start fresh for each and activate it once | One adjacent valid tile, one marker, matching text and status, focus on the activated button |
| Outside map | North three times from the Center | Third attempt keeps row 0, column 2 and reports outside the map |
| Blocked terrain | North, North, West, West from the Center | Last attempt keeps row 0, column 1 and reports blocked terrain |
| Center healing | Set hit points to 17 in the Console; move East, then West |
Center coordinate, maximum hit points, healing status, focus on West |
| Center already full | Move East, then West with full hit points | Maximum unchanged; status says already full |
| Encounter guard | Temporarily set state.activeEncounter in the Console and render |
All movement buttons disabled; a direct call cannot change position; restore null afterward |
| Keyboard | Activate with Enter and Space in separate tests | One step per activation and visible focus remains |
| Layout | Check near 320px and at 200% zoom | Controls wrap without clipping or unintended horizontal scrolling |
For the temporary encounter-guard test, set state.activeEncounter = { name: "Test encounter" } in the Console, call renderGame(), and test the guard. Restore state.activeEncounter = null and render again. This tests the movement lock before the next article creates real encounters. Do not save the test value in source.
Validate game.html with the established HTML process. Review styles.css in VS Code Problems. Repeat the trainer form, 25-tile, one-marker, and Pokédex checks from earlier stages. Remove temporary tests from saved source.
Self-check
Complete these checks against the required result.
- Confirm that four native movement buttons are outside the trainer form and initially disabled in saved game.html.
- Reload and confirm 25 tiles, zero markers, disabled movement, and no game JavaScript or asset error before setup.
- Start a valid trainer and confirm the Center marker, four enabled buttons, and focus on North.
- Move once in each direction; compare state, marker, summary, status, and focus after each move.
- Reject an outside-map and a blocked destination; confirm that the valid stored coordinate stays unchanged.
- Enter the Center with reduced and full hit points; confirm the number and matching status.
- Confirm that an active encounter disables movement and the movement guard preserves state.
- Use pointer, Enter, and Space; check one result per activation and a visible focus indicator.
- Check the game near 320px and at 200% zoom without clipping or horizontal scrolling.
- Validate HTML, review CSS diagnostics, repeat previous stage checks, and confirm no game JavaScript or asset error.
Next step or safe stopping point
Section titled “Next step or safe stopping point”Record a Git checkpoint after the complete self-check passes. Four controls now propose adjacent squares, and accepted and rejected movement produce consistent state, text, and focus. Tall grass does not yet start an encounter.
Continue to Enter a wild encounter when that article is available. Its first action will add one encounter decision after an accepted move into tall grass.