Skip to content

3. Read movement commands

Current source: curriculum/arrowfall/checkpoints/03-movement · Flecha 0.3.0 · 3/7.

The program at this checkpoint

Previously: Checkpoint 02 renders from owned Position state.

Checkpoint 02 has a trustworthy player position, but every run ends with the same value. To make the program interactive, terminal text must cross a boundary into the game model without letting arbitrary strings become rules.

This version uses an open 5×3 floor so input and mutation can be understood without collision at the same time. W, A, S, and D move; Q stops the loop; invalid text reports an error and leaves state unchanged.

Current limitation

The archer remains a diagram: no player action can mutate the position.

Required next behavior: Read W/A/S/D until Q and show a new frame after every valid command.

Model and invariants

Normalize text once, convert it to a closed Command enum, and let Game apply the typed command through a mutable checked reference.

The parser converts input text to Command once at the boundary. Game therefore handles a closed enum instead of repeating string comparisons in its rules.

The input side owns normalization and parsing. It trims ASCII whitespace, requires exactly one character, lowercases it, and converts it to a closed Command enum. Past that boundary, Game never compares strings.

The control loop follows a fixed order: draw the current state, read one command, reject an error or quit, then ask Game to apply a valid movement. The next iteration draws the mutated state, so input effects are visible without the parser knowing how rendering works.

Game::apply receives a mutable checked reference to self. Each command selects one coordinate change and checks the training-floor edge before mutation. This checkpoint writes those changes directly; checkpoint 04 will factor the repeated coordinate arithmetic into Direction and a delta.

The invariant is that x remains in 0…4 and y remains in 0…2. Invalid input and blocked edge movement are no-ops. Walls, turn costs, and victory are intentionally deferred until the text-to-command boundary is stable.

Implementation

The implementation is split into the changes below. Each excerpt is taken verbatim from the qualified checkpoint and is explained in the part of the data flow where it is used. The excerpts are not standalone programs; the complete compiled source is available after the run.

  • Add Command and CommandError.
  • Add the blocking read/parse loop.
  • Mutate Game only after successful parsing.

Commands form a closed vocabulary

Command is the vocabulary shared by parser and model. Its variants describe intent rather than keyboard spelling, so Game does not need to know that MoveUp originated as W or w.

Excerpt: src/main.fl:3–15. The complete file appears in the source section below.

src/main.fl:3-15 (excerpt)
@imports :: lib::std::text::{ascii, strings};
enum Command {
default Up,
Left,
Down,
Right,
Quit
}
error CommandError {
InvalidCommand
}

Quit belongs to the same parsed vocabulary but is handled by the loop before Game::apply. CommandError keeps malformed input separate from a legitimate command.

A closed enum also makes missing cases visible when later code matches commands. Accepting raw strings instead would allow normalization and rule logic to drift apart.

Mutation stays inside Game

Only Game mutates the private Position. The four branches pair a command with the relevant boundary and change one coordinate after that check succeeds.

Excerpt: src/main.fl:39–50. The complete file appears in the source section below.

src/main.fl:39-50 (excerpt)
void Game::apply(&self, Command command) {
if command == Command.Up and self->player.y > 0 {
self->player.y--;
} else if command == Command.Left and self->player.x > 0 {
self->player.x--;
} else if command == Command.Down and self->player.y < 2 {
self->player.y++;
} else if command == Command.Right and self->player.x < 4 {
self->player.x++;
}
}

The checked mutable reference means the caller lends Game for the duration of apply; no second owner is created. A rejected boundary leaves both coordinates untouched.

The repeated branches are acceptable for one open rectangle, but they mix direction with geometry. The next map checkpoint replaces them with a Direction-to-delta conversion before testing a proposed cell.

Text becomes a typed command

read_command is the terminal boundary. It treats end-of-input as Quit, trims the line, validates its width, normalizes case once, and returns either a Command or a typed error.

Excerpt: src/main.fl:89–116. The complete file appears in the source section below.

src/main.fl:89-116 (excerpt)
(Command, error) read_command() {
string line;
inputError := stdin("command>", &line);
if inputError != none {
return (Command.Quit, none);
}
value := strings::trim_ascii(line.view());
if value.len() != 1 {
return (none, CommandError.InvalidCommand);
}
const char KEY = ascii::to_ascii_lowercase(value[0].cast<char>);
if KEY == 'w' {
return (Command.Up, none);
}
if KEY == 'a' {
return (Command.Left, none);
}
if KEY == 's' {
return (Command.Down, none);
}
if KEY == 'd' {
return (Command.Right, none);
}
if KEY == 'q' {
return (Command.Quit, none);
}
return (none, CommandError.InvalidCommand);
}

Indexing value[0] happens only after len() confirms that one byte is present. The lowercase conversion makes W and w equivalent without duplicating branches.

The tuple carries the command and error together because the loop needs both outcomes. The parser does not mutate Game; the loop calls apply only when commandError is none.

Run and inspect the result

The command uses the checkpoint’s recorded input, so the behavior described above can be reproduced without manual timing.

Run command
longbow run tutorial/checkpoints/03-movement < tutorial/checkpoints/03-movement/tests/input.txt
Recorded standard input
Standard input
d
s
q

The recorded D, S, Q sequence draws the initial floor, moves right, moves down, and then exits. The representative frame therefore places @ at column 2, row 2.

Terminal frame produced by version 3: Read movement commands
Representative frame from the recorded run of checkpoint 3.

An unknown key prints its diagnostic and proceeds to the next loop iteration without calling apply. The unchanged following frame demonstrates that parsing failures cannot leak into state mutation.

Accessible frame transcript
Frame transcript
ARROWFALL — open training floor
.....
.....
..@..
Complete recorded stdout
Expected stdout
ARROWFALL — open training floor
.....
.@...
.....
command>ARROWFALL — open training floor
.....
..@..
.....
command>ARROWFALL — open training floor
.....
.....
..@..
command>Run ended.

What this checkpoint establishes

Terminal text now becomes a typed command before it reaches Game, and Game owns every coordinate mutation. The renderer simply observes the result on the next iteration.

All valid directions still succeed unless they cross the rectangular edge. The next checkpoint turns the original # and > symbols into map data, collision, turn counting, and victory.

Exercise

Run the recorded D, S, Q input. The final playable frame must place @ at column 2, row 2.

Complete source and verification

The sections below preserve the complete qualified source, the exact predecessor diff, and the command sequence used by CI. They are supporting evidence for the implementation explained above.

Complete tested source (3 files)

longbow.toml

longbow.toml
[package]
name = "arrowfall-step-03"
version = "0.1.0"
kind = "program"
flecha = ">=0.3.0, <0.4.0"
[dependencies]

longbow.lock

longbow.lock
{
"schema": 1,
"root": "root",
"packages": [
{
"id": "root",
"identity": "local/arrowfall_step_03",
"source": {
"kind": "workspace",
"path": "."
},
"version": "0.1.0",
"flecha": ">=0.3.0, <0.4.0",
"dependencies": {
"std": "toolchain-std"
}
},
{
"id": "toolchain-std",
"identity": "flecha/std",
"source": {
"kind": "toolchain"
},
"dependencies": {}
}
]
}

src/main.fl

src/main.fl
module main;
@imports :: lib::std::text::{ascii, strings};
enum Command {
default Up,
Left,
Down,
Right,
Quit
}
error CommandError {
InvalidCommand
}
structure Position {
i32 x;
i32 y;
};
structure Game {
private Position player;
};
Position Position::at(i32 x, i32 y) {
return build Position {
self.x = x;
self.y = y;
};
}
Game Game::start() {
return build Game {
self.player = Position::at(1, 1);
};
}
void Game::apply(&self, Command command) {
if command == Command.Up and self->player.y > 0 {
self->player.y--;
} else if command == Command.Left and self->player.x > 0 {
self->player.x--;
} else if command == Command.Down and self->player.y < 2 {
self->player.y++;
} else if command == Command.Right and self->player.x < 4 {
self->player.x++;
}
}
Position Game::playerPosition(&const self) {
return self->player;
}
void main() {
state := Game::start();
bool running = true;
while running {
draw(&state);
command, commandError := read_command();
if commandError != none {
stdoutln("Unknown command; use W/A/S/D or Q.");
proceed;
}
if command == Command.Quit {
running = false;
} else {
state.apply(command);
}
}
stdoutln("Run ended.");
}
void draw(&const Game state) {
player := state->playerPosition();
stdoutln("ARROWFALL — open training floor");
loop i32 y = 0; y < 3; y++ {
loop i32 x = 0; x < 5; x++ {
if player.x == x and player.y == y {
stdout("@");
} else {
stdout(".");
}
}
stdoutln();
}
}
(Command, error) read_command() {
string line;
inputError := stdin("command>", &line);
if inputError != none {
return (Command.Quit, none);
}
value := strings::trim_ascii(line.view());
if value.len() != 1 {
return (none, CommandError.InvalidCommand);
}
const char KEY = ascii::to_ascii_lowercase(value[0].cast<char>);
if KEY == 'w' {
return (Command.Up, none);
}
if KEY == 'a' {
return (Command.Left, none);
}
if KEY == 's' {
return (Command.Down, none);
}
if KEY == 'd' {
return (Command.Right, none);
}
if KEY == 'q' {
return (Command.Quit, none);
}
return (none, CommandError.InvalidCommand);
}
Exact diff from the preceding version
diff -rN -U0 --exclude target tutorial/checkpoints/02-state/README.md tutorial/checkpoints/03-movement/README.md
--- tutorial/checkpoints/02-state/README.md
+++ tutorial/checkpoints/03-movement/README.md
@@ -1 +1 @@
-# 02 — The picture becomes state
+# 03 — The player moves
@@ -5 +5,2 @@
-Checkpoint 01 prints a recognisable but hard-coded room.
+Checkpoint 02 derives the picture from a player position, but the position
+never changes.
@@ -9,2 +10,2 @@
-Changing a character in a string cannot express movement. The player needs a
-position owned by game state, and drawing must read that position.
+A game cannot be played without input. We first learn blocking stdin on an
+open floor, before walls add a second concern.
@@ -14,2 +15,2 @@
-`Game` owns a `Position`; the scene derives `@` from it and reports `(1, 1)`.
-The visible room is unchanged, but its source of truth is now data.
+W/A/S/D moves within a 5x3 training floor. Q exits. Input is normalised once,
+parsed into a `Command` enum, and applied through a mutable game reference.
@@ -28,2 +29 @@
-See `../../diffs/02-state.patch` for the exact predecessor diff. All current
-source is present in this directory.
+See `../../diffs/03-movement.patch` for the exact predecessor diff.
@@ -34,4 +34,4 @@
-longbow check tutorial/checkpoints/02-state
-longbow test tutorial/checkpoints/02-state
-longbow build tutorial/checkpoints/02-state
-longbow run tutorial/checkpoints/02-state < tutorial/checkpoints/02-state/tests/input.txt
+longbow check tutorial/checkpoints/03-movement
+longbow test tutorial/checkpoints/03-movement
+longbow build tutorial/checkpoints/03-movement
+longbow run tutorial/checkpoints/03-movement < tutorial/checkpoints/03-movement/tests/input.txt
@@ -43 +43,2 @@
-2. **The picture becomes state** — structs and methods own the player position.
+2. The picture becomes state — `Game` owns `Position`.
+3. **The player moves** — blocking stdin drives mutation.
@@ -47,2 +48,2 @@
-Set the start position to `(2, 1)`: both `@` and the reported coordinate must
-move. Restore `(1, 1)` afterward.
+Run the recorded input. The last frame before `Run ended.` must place `@` at
+column 2, row 2; an unknown key must not move it.
diff -rN -U0 --exclude target tutorial/checkpoints/02-state/longbow.lock tutorial/checkpoints/03-movement/longbow.lock
--- tutorial/checkpoints/02-state/longbow.lock
+++ tutorial/checkpoints/03-movement/longbow.lock
@@ -7 +7 @@
- "identity": "local/arrowfall_step_02",
+ "identity": "local/arrowfall_step_03",
diff -rN -U0 --exclude target tutorial/checkpoints/02-state/longbow.toml tutorial/checkpoints/03-movement/longbow.toml
--- tutorial/checkpoints/02-state/longbow.toml
+++ tutorial/checkpoints/03-movement/longbow.toml
@@ -2 +2 @@
-name = "arrowfall-step-02"
+name = "arrowfall-step-03"
diff -rN -U0 --exclude target tutorial/checkpoints/02-state/src/main.fl tutorial/checkpoints/03-movement/src/main.fl
--- tutorial/checkpoints/02-state/src/main.fl
+++ tutorial/checkpoints/03-movement/src/main.fl
@@ -2,0 +3,14 @@
+@imports :: lib::std::text::{ascii, strings};
+
+enum Command {
+ default Up,
+ Left,
+ Down,
+ Right,
+ Quit
+}
+
+error CommandError {
+ InvalidCommand
+}
+
@@ -24,0 +39,12 @@
+void Game::apply(&self, Command command) {
+ if command == Command.Up and self->player.y > 0 {
+ self->player.y--;
+ } else if command == Command.Left and self->player.x > 0 {
+ self->player.x--;
+ } else if command == Command.Down and self->player.y < 2 {
+ self->player.y++;
+ } else if command == Command.Right and self->player.x < 4 {
+ self->player.x++;
+ }
+}
+
@@ -31,6 +57,15 @@
- player := state.playerPosition();
- stdoutln("ARROWFALL");
- stdoutln("#####");
- draw_row(player);
- stdoutln("#####");
- stdoutln("Player: (", player.x, ", ", player.y, ")");
+ bool running = true;
+ while running {
+ draw(&state);
+ command, commandError := read_command();
+ if commandError != none {
+ stdoutln("Unknown command; use W/A/S/D or Q.");
+ proceed;
+ }
+ if command == Command.Quit {
+ running = false;
+ } else {
+ state.apply(command);
+ }
+ }
+ stdoutln("Run ended.");
@@ -39,9 +74,10 @@
-void draw_row(Position player) {
- stdout("#");
- loop i32 x = 1; x < 4; x++ {
- if player.x == x and player.y == 1 {
- stdout("@");
- } else if x == 3 {
- stdout(">");
- } else {
- stdout(".");
+void draw(&const Game state) {
+ player := state->playerPosition();
+ stdoutln("ARROWFALL — open training floor");
+ loop i32 y = 0; y < 3; y++ {
+ loop i32 x = 0; x < 5; x++ {
+ if player.x == x and player.y == y {
+ stdout("@");
+ } else {
+ stdout(".");
+ }
@@ -48,0 +85,29 @@
+ stdoutln();
+ }
+}
+
+(Command, error) read_command() {
+ string line;
+ inputError := stdin("command>", &line);
+ if inputError != none {
+ return (Command.Quit, none);
+ }
+ value := strings::trim_ascii(line.view());
+ if value.len() != 1 {
+ return (none, CommandError.InvalidCommand);
+ }
+ const char KEY = ascii::to_ascii_lowercase(value[0].cast<char>);
+ if KEY == 'w' {
+ return (Command.Up, none);
+ }
+ if KEY == 'a' {
+ return (Command.Left, none);
+ }
+ if KEY == 's' {
+ return (Command.Down, none);
+ }
+ if KEY == 'd' {
+ return (Command.Right, none);
+ }
+ if KEY == 'q' {
+ return (Command.Quit, none);
@@ -50 +115 @@
- stdoutln("#");
+ return (none, CommandError.InvalidCommand);
diff -rN -U0 --exclude target tutorial/checkpoints/02-state/tests/frame.svg tutorial/checkpoints/03-movement/tests/frame.svg
--- tutorial/checkpoints/02-state/tests/frame.svg
+++ tutorial/checkpoints/03-movement/tests/frame.svg
@@ -2,2 +2,2 @@
-<svg xmlns="http://www.w3.org/2000/svg" width="360" height="143" viewBox="0 0 360 143" role="img">
- <title>02-state representative frame</title>
+<svg xmlns="http://www.w3.org/2000/svg" width="360" height="124" viewBox="0 0 360 124" role="img">
+ <title>03-movement representative frame</title>
@@ -6,5 +6,4 @@
- <text x="24" y="37" class="terminal">ARROWFALL</text>
- <text x="24" y="56" class="terminal">#####</text>
- <text x="24" y="75" class="terminal">#@.&gt;#</text>
- <text x="24" y="94" class="terminal">#####</text>
- <text x="24" y="113" class="terminal">Player: (1, 1)</text>
+ <text x="24" y="37" class="terminal">ARROWFALL — open training floor</text>
+ <text x="24" y="56" class="terminal">.....</text>
+ <text x="24" y="75" class="terminal">.....</text>
+ <text x="24" y="94" class="terminal">..@..</text>
diff -rN -U0 --exclude target tutorial/checkpoints/02-state/tests/frame.txt tutorial/checkpoints/03-movement/tests/frame.txt
--- tutorial/checkpoints/02-state/tests/frame.txt
+++ tutorial/checkpoints/03-movement/tests/frame.txt
@@ -1,5 +1,4 @@
-ARROWFALL
-#####
-#@.>#
-#####
-Player: (1, 1)
+ARROWFALL — open training floor
+.....
+.....
+..@..
diff -rN -U0 --exclude target tutorial/checkpoints/02-state/tests/input.txt tutorial/checkpoints/03-movement/tests/input.txt
--- tutorial/checkpoints/02-state/tests/input.txt
+++ tutorial/checkpoints/03-movement/tests/input.txt
@@ -0,0 +1,3 @@
+d
+s
+q
diff -rN -U0 --exclude target tutorial/checkpoints/02-state/tests/output.txt tutorial/checkpoints/03-movement/tests/output.txt
--- tutorial/checkpoints/02-state/tests/output.txt
+++ tutorial/checkpoints/03-movement/tests/output.txt
@@ -1,5 +1,13 @@
-ARROWFALL
-#####
-#@.>#
-#####
-Player: (1, 1)
+ARROWFALL — open training floor
+.....
+.@...
+.....
+command>ARROWFALL — open training floor
+.....
+..@..
+.....
+command>ARROWFALL — open training floor
+.....
+.....
+..@..
+command>Run ended.
Check, test, build, and run commands
Verification commands
longbow check tutorial/checkpoints/03-movement
longbow test tutorial/checkpoints/03-movement
longbow build tutorial/checkpoints/03-movement
longbow run tutorial/checkpoints/03-movement < tutorial/checkpoints/03-movement/tests/input.txt

Versions in this tutorial

  1. 1. Render the first room
  2. 2. Represent game state
  3. 3. Read movement commands

Next version

Movement is limited only by the rectangular edge. The next version adds map cells, wall collision, and an exit.

Add the map and collision

References

Source: Arrowfall commit 56bb003, verified with official Flecha 0.3.0. The source, input, output, diff, and frame on this page are read from that committed snapshot.