Skip to content

6. Add archery and line of sight

Current source: curriculum/arrowfall/checkpoints/06-archery · Flecha 0.3.0 · 6/7.

The program at this checkpoint

Previously: Checkpoint 05 has a fair pursuer, visible danger, HP, and deterministic defeat.

Checkpoint 05 creates a fair threat, but the archer has only movement commands. The map already contains corridors and walls, so it can also answer a new question: what lies along a chosen direction?

This checkpoint adds three arrows and I, J, K, L shot commands. A shot consumes one arrow, advances cell by cell, stops at a wall, and kills the goblin if the cursor reaches its position first.

Current limitation

The player can only flee; “archer” is flavor text and map sight lines do no work.

Required next behavior: Trade one of three arrows for a directional shot with distinct hit, miss, and empty-quiver results.

Model and invariants

Reuse Direction for I/J/K/L. A shot walks one checked cell at a time until wall or goblin.

Movement and shooting reuse Direction but remain different commands because they consume different resources and stop on different conditions. Tests preserve that distinction.

Movement and shooting share Direction because both choose up, left, down, or right. They remain distinct Command variants because their resources and stopping rules differ. Reusing direction does not collapse two different actions into one.

Game::apply preserves the TurnResult contract from checkpoint 05. It records DANGER_BEFORE, delegates to movePlayer or shoot, then decides whether a no-op is free or consumes a threatened turn. The caller still receives one player event and one enemy response.

shoot checks the empty quiver before decrementing. With an arrow available, the cursor starts in the adjacent cell and advances until tile_at returns #. Encountering the live goblin first changes goblinAlive and returns ShotHit; reaching a wall returns ShotMiss.

The line-of-sight loop relies on the map invariant established in checkpoint 04: outside coordinates read as walls. It therefore terminates even when fired toward the map edge. Arrows do not pass through walls, and one command consumes at most one arrow.

The player cannot recover arrows in this project. That keeps inventory finite and visible without adding pickups or allocation, which are outside the combat rule being introduced.

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 shoot commands and inventory.
  • Trace line of sight to a wall.
  • Make parsing testable.

Movement and shooting share the turn contract

apply classifies the command after preserving prior danger. is_shot chooses the action family; both helpers return PlayerEvent, allowing the existing turn policy to process their outcomes uniformly.

Excerpt: src/game.fl:119–158. The complete file appears in the source section below.

src/game.fl:119-158 (excerpt)
TurnResult Game::apply(&self, Command command) {
if command == Command.Help {
return TurnResult::make(PlayerEvent.Help);
}
if command == Command.Quit {
self->gameOutcome = Outcome.Quit;
return TurnResult::make(PlayerEvent.Quit);
}
const bool DANGER_BEFORE = self->goblinAlive and
adjacent(self->goblin, self->player);
PlayerEvent action = PlayerEvent.None;
if is_shot(command) {
action = self->shoot(command_direction(command));
} else {
action = self->movePlayer(command_direction(command));
}
if action == PlayerEvent.Wall or
action == PlayerEvent.GoblinBlocks or
action == PlayerEvent.NoArrows {
if !DANGER_BEFORE {
return TurnResult::make(action);
}
self->turnCount++;
return TurnResult::make(
action,
self->goblinTurn(true),
true
);
}
self->turnCount++;
if tile_at(self->player) == '>' {
self->gameOutcome = Outcome.Won;
return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true);
}
return TurnResult::make(
action,
self->goblinTurn(DANGER_BEFORE),
true
);
}

Wall, GoblinBlocks, and NoArrows are no-ops. They stay free while safe but permit an attack if danger existed before the command. A hit or miss consumes a normal turn and then allows the goblin response if it survived.

The exit check remains after the player action. Shooting cannot move the player, so it cannot accidentally win; movement can enter > under the same rule as before.

Line of sight consumes one arrow

movePlayer and shoot isolate their different state transitions. The shot method makes resource consumption happen once, before line-of-sight traversal.

Excerpt: src/game.fl:160–186. The complete file appears in the source section below.

src/game.fl:160-186 (excerpt)
private PlayerEvent Game::movePlayer(&self, Direction direction) {
next := self->player.moved(direction);
if tile_at(next) == '#' {
return PlayerEvent.Wall;
}
if self->goblinAlive and next.equals(&self->goblin) {
return PlayerEvent.GoblinBlocks;
}
self->player = next;
return PlayerEvent.Moved;
}
private PlayerEvent Game::shoot(&self, Direction direction) {
if self->arrowCount == 0 {
return PlayerEvent.NoArrows;
}
self->arrowCount--;
cursor := self->player.moved(direction);
while tile_at(cursor) != '#' {
if self->goblinAlive and cursor.equals(&self->goblin) {
self->goblinAlive = false;
return PlayerEvent.ShotHit;
}
cursor = cursor.moved(direction);
}
return PlayerEvent.ShotMiss;
}

NoArrows returns before decrementing or creating a cursor. Otherwise arrowCount decreases exactly once regardless of hit or miss. Each loop iteration examines one cell before advancing in the same Direction.

Setting goblinAlive false is the only combat mutation. The later goblinTurn begins by checking that flag, so a goblin killed by this shot cannot respond in the same turn.

Both key diamonds are tested

Input tests keep the two directional key diamonds distinct at the parser boundary. Uppercase W must become movement, while lowercase l must become a rightward shot.

Excerpt: src/input_test.fl:1–18. The complete file appears in the source section below.

src/input_test.fl:1-18 (excerpt)
module input_test;
@imports
:: root::game
:: root::input
:: lib::std::tests::asserts
:: lib::std::tests::testing;
test direct_key_diamonds_parse(&testing::UnitTest t) {
move, moveError := input::parse("W");
shot, shotError := input::parse("l");
asserts::is_true(
t,
moveError == none and move == game::Command.MoveUp and
shotError == none and shot == game::Command.ShootRight,
"WASD moves and IJKL shoots after one lowercase normalization"
);
}

The assertion verifies both parsed commands and both error values. One lowercase normalization supports either input case without changing the enum seen by Game.

Testing parse directly separates keyboard mapping regressions from combat regressions; game tests can focus on line of sight, ammunition, and turn effects.

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/06-archery < tutorial/checkpoints/06-archery/tests/input.txt
Recorded standard input
Standard input
s
s
l
s
s

The recorded S, S, L, S, S route aligns the player with the goblin, shoots right, then reaches the exit. The kill occurs on turn 3 and the win on turn 5 with 3 HP and 2 arrows.

Terminal frame produced by version 6: Add archery and line of sight
Representative frame from the recorded run of checkpoint 6.

An upward shot from the starting cell demonstrates the other branch: it consumes one arrow, meets the boundary wall, and returns ShotMiss. The transcript distinguishes hit, miss, and empty quiver instead of reducing them to one boolean.

Accessible frame transcript
Frame transcript
#########
#.......#
#..###..#
#.......#
#..###..#
#@......#
#########
HP: 3 Arrows: 2 Turn: 5
Complete recorded stdout
Expected stdout
#########
#@......#
#..###..#
#......g#
#..###..#
#>......#
#########
HP: 3 Arrows: 3 Turn: 0
command>
You move.
The goblin moves closer.
#########
#.......#
#@.###..#
#.....g.#
#..###..#
#>......#
#########
HP: 3 Arrows: 3 Turn: 1
command>
You move.
The goblin moves closer.
#########
#.......#
#..###..#
#@...g..#
#..###..#
#>......#
#########
HP: 3 Arrows: 3 Turn: 2
command>
Your arrow hits! The goblin falls.
#########
#.......#
#..###..#
#@......#
#..###..#
#>......#
#########
HP: 3 Arrows: 2 Turn: 3
command>
You move.
#########
#.......#
#..###..#
#.......#
#@.###..#
#>......#
#########
HP: 3 Arrows: 2 Turn: 4
command>
You reach the exit.
#########
#.......#
#..###..#
#.......#
#..###..#
#@......#
#########
HP: 3 Arrows: 2 Turn: 5
You escaped Arrowfall!

What this checkpoint establishes

Arrowfall now has movement, pursuit, danger, a limited ranged action, and two possible successful responses: escape or defeat the threat before escaping.

The rules are complete, but pursuit ties always choose the same axis and interactive redraws are poor golden evidence. The final checkpoint adds controlled variation and release-facing options without changing combat policy.

Exercise

S S L S S must kill on turn 3 and exit on turn 5 with 3 HP and 2 arrows. An upward shot from start must miss.

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 (8 files)

longbow.toml

longbow.toml
[package]
name = "arrowfall-step-06"
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_06",
"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/game.fl

src/game.fl
module game;
const i32 WIDTH = 9;
const i32 HEIGHT = 7;
export enum Direction {
default Up,
Left,
Down,
Right
}
export enum Command {
default MoveUp,
MoveLeft,
MoveDown,
MoveRight,
ShootUp,
ShootLeft,
ShootDown,
ShootRight,
Help,
Quit
}
export enum Outcome {
default Playing,
Won,
Lost,
Quit
}
export enum PlayerEvent {
default None,
Moved,
Wall,
GoblinBlocks,
ShotHit,
ShotMiss,
NoArrows,
Help,
Won,
Quit
}
export enum EnemyEvent {
default None,
Moved,
Threatened,
Attacked
}
export structure Position {
i32 x;
i32 y;
};
export structure TurnResult {
PlayerEvent player;
EnemyEvent enemy;
bool spentTurn;
};
export structure Game {
private Position player;
private Position goblin;
private bool goblinAlive;
private i32 playerHealth;
private u32 arrowCount;
private u32 turnCount;
private Outcome gameOutcome;
};
TurnResult TurnResult::make(
PlayerEvent player,
EnemyEvent enemy = EnemyEvent.None,
bool spentTurn = false
) {
return build TurnResult {
self.player = player;
self.enemy = enemy;
self.spentTurn = spentTurn;
};
}
Position Position::at(i32 x, i32 y) {
return build Position {
self.x = x;
self.y = y;
};
}
bool Position::equals(&const self, &const Position other) {
return self->x == other->x and self->y == other->y;
}
Position Position::moved(&const self, Direction direction) {
Position delta = match direction {
Direction.Up => Position::at(0, -1),
Direction.Left => Position::at(-1, 0),
Direction.Down => Position::at(0, 1),
Direction.Right => Position::at(1, 0),
};
return Position::at(self->x + delta.x, self->y + delta.y);
}
Game Game::start() {
return build Game {
self.player = Position::at(1, 1);
self.goblin = Position::at(7, 3);
self.goblinAlive = true;
self.playerHealth = 3;
self.arrowCount = 3;
self.turnCount = 0;
self.gameOutcome = Outcome.Playing;
};
}
TurnResult Game::apply(&self, Command command) {
if command == Command.Help {
return TurnResult::make(PlayerEvent.Help);
}
if command == Command.Quit {
self->gameOutcome = Outcome.Quit;
return TurnResult::make(PlayerEvent.Quit);
}
const bool DANGER_BEFORE = self->goblinAlive and
adjacent(self->goblin, self->player);
PlayerEvent action = PlayerEvent.None;
if is_shot(command) {
action = self->shoot(command_direction(command));
} else {
action = self->movePlayer(command_direction(command));
}
if action == PlayerEvent.Wall or
action == PlayerEvent.GoblinBlocks or
action == PlayerEvent.NoArrows {
if !DANGER_BEFORE {
return TurnResult::make(action);
}
self->turnCount++;
return TurnResult::make(
action,
self->goblinTurn(true),
true
);
}
self->turnCount++;
if tile_at(self->player) == '>' {
self->gameOutcome = Outcome.Won;
return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true);
}
return TurnResult::make(
action,
self->goblinTurn(DANGER_BEFORE),
true
);
}
private PlayerEvent Game::movePlayer(&self, Direction direction) {
next := self->player.moved(direction);
if tile_at(next) == '#' {
return PlayerEvent.Wall;
}
if self->goblinAlive and next.equals(&self->goblin) {
return PlayerEvent.GoblinBlocks;
}
self->player = next;
return PlayerEvent.Moved;
}
private PlayerEvent Game::shoot(&self, Direction direction) {
if self->arrowCount == 0 {
return PlayerEvent.NoArrows;
}
self->arrowCount--;
cursor := self->player.moved(direction);
while tile_at(cursor) != '#' {
if self->goblinAlive and cursor.equals(&self->goblin) {
self->goblinAlive = false;
return PlayerEvent.ShotHit;
}
cursor = cursor.moved(direction);
}
return PlayerEvent.ShotMiss;
}
private EnemyEvent Game::goblinTurn(&self, bool canAttack) {
if !self->goblinAlive {
return EnemyEvent.None;
}
if canAttack and adjacent(self->goblin, self->player) {
self->playerHealth--;
if self->playerHealth == 0 {
self->gameOutcome = Outcome.Lost;
}
return EnemyEvent.Attacked;
}
if adjacent(self->goblin, self->player) {
return EnemyEvent.Threatened;
}
const i32 DX = self->player.x - self->goblin.x;
const i32 DY = self->player.y - self->goblin.y;
Direction horizontal = Direction.Right;
Direction vertical = Direction.Down;
if DX < 0 {
horizontal = Direction.Left;
}
if DY < 0 {
vertical = Direction.Up;
}
bool moved = false;
if DX != 0 {
moved = self->tryGoblinStep(horizontal);
}
if !moved and DY != 0 {
moved = self->tryGoblinStep(vertical);
}
if moved and adjacent(self->goblin, self->player) {
return EnemyEvent.Threatened;
}
if moved {
return EnemyEvent.Moved;
}
return EnemyEvent.None;
}
private bool Game::tryGoblinStep(&self, Direction direction) {
next := self->goblin.moved(direction);
if next.equals(&self->player) {
return false;
}
if tile_at(next) != '#' and tile_at(next) != '>' {
self->goblin = next;
return true;
}
return false;
}
Position Game::playerPosition(&const self) {
return self->player;
}
Position Game::goblinPosition(&const self) {
return self->goblin;
}
bool Game::isGoblinAlive(&const self) {
return self->goblinAlive;
}
i32 Game::health(&const self) {
return self->playerHealth;
}
u32 Game::arrows(&const self) {
return self->arrowCount;
}
u32 Game::turn(&const self) {
return self->turnCount;
}
Outcome Game::outcome(&const self) {
return self->gameOutcome;
}
export char tile_at(Position position) {
[63]char tiles = [
'#','#','#','#','#','#','#','#','#',
'#','.','.','.','.','.','.','.','#',
'#','.','.','#','#','#','.','.','#',
'#','.','.','.','.','.','.','.','#',
'#','.','.','#','#','#','.','.','#',
'#','>','.','.','.','.','.','.','#',
'#','#','#','#','#','#','#','#','#'
];
return tile_from_cells(tiles[:], position);
}
char tile_from_cells([]const char tiles, Position position) {
if position.x < 0 or position.x >= WIDTH or
position.y < 0 or position.y >= HEIGHT {
return '#';
}
return tiles[(position.y * WIDTH + position.x).cast<usize>];
}
Direction command_direction(Command command) {
return match command {
Command.MoveUp => Direction.Up,
Command.MoveLeft => Direction.Left,
Command.MoveDown => Direction.Down,
Command.MoveRight => Direction.Right,
Command.ShootUp => Direction.Up,
Command.ShootLeft => Direction.Left,
Command.ShootDown => Direction.Down,
Command.ShootRight => Direction.Right,
Command.Help => Direction.Up,
Command.Quit => Direction.Up,
};
}
bool is_shot(Command command) {
return command == Command.ShootUp or command == Command.ShootLeft or
command == Command.ShootDown or command == Command.ShootRight;
}
bool adjacent(Position left, Position right) {
const i32 DX = left.x - right.x;
const i32 DY = left.y - right.y;
return (DX == 0 and (DY == 1 or DY == -1)) or
(DY == 0 and (DX == 1 or DX == -1));
}

src/game_test.fl

src/game_test.fl
module game_test;
@imports
:: root::game
:: lib::std::tests::asserts
:: lib::std::tests::testing;
test safe_wall_is_free(&testing::UnitTest t) {
state := game::Game::start();
wall := state.apply(game::Command.MoveUp);
asserts::is_true(
t,
wall.player == game::PlayerEvent.Wall and !wall.spentTurn and
state.turn() == 0,
"a safe wall bump does not advance the goblin"
);
}
test approach_warns_before_a_later_attack(&testing::UnitTest t) {
state := game::Game::start();
state.apply(game::Command.MoveRight);
state.apply(game::Command.MoveLeft);
state.apply(game::Command.MoveRight);
state.apply(game::Command.MoveLeft);
state.apply(game::Command.MoveRight);
state.apply(game::Command.MoveLeft);
warning := state.apply(game::Command.MoveDown);
blocked := state.apply(game::Command.MoveDown);
asserts::is_true(
t,
warning.enemy == game::EnemyEvent.Threatened and
blocked.enemy == game::EnemyEvent.Attacked and
state.health() == 2,
"the first adjacency warns and a later failed response takes one HP"
);
}

src/input.fl

src/input.fl
module input;
@imports :: root::game :: lib::std::text::{ascii, strings};
export error CommandError {
InvalidCommand
}
export (game::Command, error) parse(stringv text) {
value := strings::trim_ascii(text);
if value.len() != 1 {
return (none, CommandError.InvalidCommand);
}
const char KEY = ascii::to_ascii_lowercase(value[0].cast<char>);
if KEY == 'w' {
return (game::Command.MoveUp, none);
}
if KEY == 'a' {
return (game::Command.MoveLeft, none);
}
if KEY == 's' {
return (game::Command.MoveDown, none);
}
if KEY == 'd' {
return (game::Command.MoveRight, none);
}
if KEY == 'i' {
return (game::Command.ShootUp, none);
}
if KEY == 'j' {
return (game::Command.ShootLeft, none);
}
if KEY == 'k' {
return (game::Command.ShootDown, none);
}
if KEY == 'l' {
return (game::Command.ShootRight, none);
}
if KEY == 'h' {
return (game::Command.Help, none);
}
if KEY == 'q' {
return (game::Command.Quit, none);
}
return (none, CommandError.InvalidCommand);
}
export (game::Command, error) read_command() {
string line;
inputError := stdin("command>", &line);
if inputError != none {
return (game::Command.Quit, none);
}
return parse(line.view());
}

src/input_test.fl

src/input_test.fl
module input_test;
@imports
:: root::game
:: root::input
:: lib::std::tests::asserts
:: lib::std::tests::testing;
test direct_key_diamonds_parse(&testing::UnitTest t) {
move, moveError := input::parse("W");
shot, shotError := input::parse("l");
asserts::is_true(
t,
moveError == none and move == game::Command.MoveUp and
shotError == none and shot == game::Command.ShootRight,
"WASD moves and IJKL shoots after one lowercase normalization"
);
}

src/main.fl

src/main.fl
module main;
@imports :: root::game :: root::input :: root::render;
void main() {
state := game::Game::start();
while state.outcome() == game::Outcome.Playing {
frame, frameError := render::draw(&state);
if frameError != none {
stdoutln("Could not render the room.");
return;
}
stdoutln(frame);
command, commandError := input::read_command();
stdoutln();
if commandError != none {
stdoutln("Unknown command. No turn spent.");
proceed;
}
result := state.apply(command);
stdoutln(render::player_message(result.player));
enemyText := render::enemy_message(result.enemy);
if enemyText.len() > 0 {
stdoutln(enemyText);
}
}
finalFrame, finalError := render::draw(&state);
if finalError == none {
stdoutln(finalFrame);
}
if state.outcome() == game::Outcome.Won {
stdoutln("You escaped Arrowfall!");
} else if state.outcome() == game::Outcome.Lost {
stdoutln("The goblin caught you.");
}
}

src/render.fl

src/render.fl
module render;
@imports :: root::game :: lib::std::text::strings;
export (string, error) draw(&const game::Game state) {
canvas, allocationError := strings::StringBuilder::withCapacity(112);
if allocationError != none {
return (none, allocationError);
}
player := state->playerPosition();
goblin := state->goblinPosition();
loop i32 y = 0; y < 7; y++ {
loop i32 x = 0; x < 9; x++ {
position := game::Position::at(x, y);
char tile = game::tile_at(position);
if position.equals(&player) {
tile = '@';
} else if state->isGoblinAlive() and position.equals(&goblin) {
tile = 'g';
}
if canvas.appendChar(tile) != none {
return (none, AllocationError.OutOfMemory);
}
}
if canvas.appendChar('\n') != none {
return (none, AllocationError.OutOfMemory);
}
}
if canvas.append("HP: ") != none or
canvas.appendInteger(state->health()) != none or
canvas.append(" Arrows: ") != none or
canvas.appendInteger(state->arrows()) != none or
canvas.append(" Turn: ") != none or
canvas.appendInteger(state->turn()) != none {
return (none, AllocationError.OutOfMemory);
}
return canvas.toString();
}
export stringv player_message(game::PlayerEvent event) {
if event == game::PlayerEvent.Wall {
return "A wall blocks your way. No turn spent.";
}
if event == game::PlayerEvent.GoblinBlocks {
return "The goblin blocks that tile.";
}
if event == game::PlayerEvent.ShotHit {
return "Your arrow hits! The goblin falls.";
}
if event == game::PlayerEvent.ShotMiss {
return "Your arrow misses; a wall stops it.";
}
if event == game::PlayerEvent.NoArrows {
return "Your quiver is empty.";
}
if event == game::PlayerEvent.Help {
return "Move Shoot\n W I\nA S D J K L\nH help | Q quit";
}
if event == game::PlayerEvent.Won {
return "You reach the exit.";
}
if event == game::PlayerEvent.Quit {
return "You leave the room.";
}
return "You move.";
}
export stringv enemy_message(game::EnemyEvent event) {
if event == game::EnemyEvent.Moved {
return "The goblin moves closer.";
}
if event == game::EnemyEvent.Threatened {
return "The goblin closes in! Move before its next turn.";
}
if event == game::EnemyEvent.Attacked {
return "The goblin attacks: -1 HP.";
}
return "";
}
Exact diff from the preceding version
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/README.md tutorial/checkpoints/06-archery/README.md
--- tutorial/checkpoints/05-goblin/README.md
+++ tutorial/checkpoints/06-archery/README.md
@@ -1 +1 @@
-# 05 — A turn now has consequences
+# 06 — The archer earns the name
@@ -5,2 +5 @@
-Checkpoint 04 has meaningful walls and an exit, but the room is safe and the
-shortest path is automatic.
+Checkpoint 05 adds pressure and fair warning, but the only response is to run.
@@ -10,2 +9,2 @@
-Without pressure there is no reason to weigh one movement against another. A
-single pursuer makes the turn model visible before combat adds another action.
+An archer should trade a scarce resource for safety. The existing map already
+makes direction and line of sight meaningful.
@@ -15,4 +14,4 @@
-A goblin takes one response after each effective player turn. First adjacency
-only warns. If the player begins the next action adjacent and fails to escape,
-the goblin deals one HP; safe no-ops remain free. `TurnResult` carries player
-and enemy events so rules are observable instead of inferred by the CLI.
+I/J/K/L shoots up/left/down/right. A shot consumes one of three arrows, walks
+cell by cell until a wall, and kills the goblin if the line crosses it. Hit,
+miss, and empty-quiver outcomes are distinct; twin control diamonds make both
+directional action families visible.
@@ -27,0 +27 @@
+- `src/input_test.fl`
@@ -35 +35 @@
-Exact changes: `../../diffs/05-goblin.patch`.
+Exact changes: `../../diffs/06-archery.patch`.
@@ -40,4 +40,4 @@
-longbow check tutorial/checkpoints/05-goblin
-longbow test tutorial/checkpoints/05-goblin
-longbow build tutorial/checkpoints/05-goblin
-longbow run tutorial/checkpoints/05-goblin < tutorial/checkpoints/05-goblin/tests/input.txt
+longbow check tutorial/checkpoints/06-archery
+longbow test tutorial/checkpoints/06-archery
+longbow build tutorial/checkpoints/06-archery
+longbow run tutorial/checkpoints/06-archery < tutorial/checkpoints/06-archery/tests/input.txt
@@ -52 +52,2 @@
-5. **A turn has consequences** — pursuit, warning, HP, defeat, events.
+5. A goblin creates turns, threat, and defeat.
+6. **The archer earns the name** — IJKL, arrows, line of sight, combat win.
@@ -56,2 +57,2 @@
-The first adjacent frame must show a warning at 3 HP. Only a later failed
-response may show `-1 HP`; the goblin must never move and attack together.
+The recorded `S S L S S` run must kill the goblin on turn 3 and reach the exit
+on turn 5 with 3 HP and 2 arrows. An upward shot from start must miss.
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/longbow.lock tutorial/checkpoints/06-archery/longbow.lock
--- tutorial/checkpoints/05-goblin/longbow.lock
+++ tutorial/checkpoints/06-archery/longbow.lock
@@ -7 +7 @@
- "identity": "local/arrowfall_step_05",
+ "identity": "local/arrowfall_step_06",
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/longbow.toml tutorial/checkpoints/06-archery/longbow.toml
--- tutorial/checkpoints/05-goblin/longbow.toml
+++ tutorial/checkpoints/06-archery/longbow.toml
@@ -2 +2 @@
-name = "arrowfall-step-05"
+name = "arrowfall-step-06"
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/src/game.fl tutorial/checkpoints/06-archery/src/game.fl
--- tutorial/checkpoints/05-goblin/src/game.fl
+++ tutorial/checkpoints/06-archery/src/game.fl
@@ -17,0 +18,4 @@
+ ShootUp,
+ ShootLeft,
+ ShootDown,
+ ShootRight,
@@ -33,0 +38,3 @@
+ ShotHit,
+ ShotMiss,
+ NoArrows,
@@ -59,0 +67 @@
+ private bool goblinAlive;
@@ -60,0 +69 @@
+ private u32 arrowCount;
@@ -101,0 +111 @@
+ self.goblinAlive = true;
@@ -102,0 +113 @@
+ self.arrowCount = 3;
@@ -116,10 +127,11 @@
- const bool DANGER_BEFORE = adjacent(self->goblin, self->player);
- next := self->player.moved(command_direction(command));
- if tile_at(next) == '#' {
- if !DANGER_BEFORE {
- return TurnResult::make(PlayerEvent.Wall);
- }
- self->turnCount++;
- return TurnResult::make(PlayerEvent.Wall, self->goblinTurn(true), true);
- }
- if next.equals(&self->goblin) {
+ const bool DANGER_BEFORE = self->goblinAlive and
+ adjacent(self->goblin, self->player);
+ PlayerEvent action = PlayerEvent.None;
+ if is_shot(command) {
+ action = self->shoot(command_direction(command));
+ } else {
+ action = self->movePlayer(command_direction(command));
+ }
+ if action == PlayerEvent.Wall or
+ action == PlayerEvent.GoblinBlocks or
+ action == PlayerEvent.NoArrows {
@@ -127 +139 @@
- return TurnResult::make(PlayerEvent.GoblinBlocks);
+ return TurnResult::make(action);
@@ -131 +143 @@
- PlayerEvent.GoblinBlocks,
+ action,
@@ -136 +147,0 @@
- self->player = next;
@@ -143 +154 @@
- PlayerEvent.Moved,
+ action,
@@ -148,0 +160,28 @@
+private PlayerEvent Game::movePlayer(&self, Direction direction) {
+ next := self->player.moved(direction);
+ if tile_at(next) == '#' {
+ return PlayerEvent.Wall;
+ }
+ if self->goblinAlive and next.equals(&self->goblin) {
+ return PlayerEvent.GoblinBlocks;
+ }
+ self->player = next;
+ return PlayerEvent.Moved;
+}
+
+private PlayerEvent Game::shoot(&self, Direction direction) {
+ if self->arrowCount == 0 {
+ return PlayerEvent.NoArrows;
+ }
+ self->arrowCount--;
+ cursor := self->player.moved(direction);
+ while tile_at(cursor) != '#' {
+ if self->goblinAlive and cursor.equals(&self->goblin) {
+ self->goblinAlive = false;
+ return PlayerEvent.ShotHit;
+ }
+ cursor = cursor.moved(direction);
+ }
+ return PlayerEvent.ShotMiss;
+}
+
@@ -149,0 +189,3 @@
+ if !self->goblinAlive {
+ return EnemyEvent.None;
+ }
@@ -205,0 +248,4 @@
+bool Game::isGoblinAlive(&const self) {
+ return self->goblinAlive;
+}
+
@@ -209,0 +256,4 @@
+u32 Game::arrows(&const self) {
+ return self->arrowCount;
+}
+
@@ -244,0 +295,4 @@
+ Command.ShootUp => Direction.Up,
+ Command.ShootLeft => Direction.Left,
+ Command.ShootDown => Direction.Down,
+ Command.ShootRight => Direction.Right,
@@ -247,0 +302,5 @@
+}
+
+bool is_shot(Command command) {
+ return command == Command.ShootUp or command == Command.ShootLeft or
+ command == Command.ShootDown or command == Command.ShootRight;
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/src/input.fl tutorial/checkpoints/06-archery/src/input.fl
--- tutorial/checkpoints/05-goblin/src/input.fl
+++ tutorial/checkpoints/06-archery/src/input.fl
@@ -9,7 +9,2 @@
-export (game::Command, error) read_command() {
- string line;
- inputError := stdin("command>", &line);
- if inputError != none {
- return (game::Command.Quit, none);
- }
- value := strings::trim_ascii(line.view());
+export (game::Command, error) parse(stringv text) {
+ value := strings::trim_ascii(text);
@@ -31,0 +27,12 @@
+ if KEY == 'i' {
+ return (game::Command.ShootUp, none);
+ }
+ if KEY == 'j' {
+ return (game::Command.ShootLeft, none);
+ }
+ if KEY == 'k' {
+ return (game::Command.ShootDown, none);
+ }
+ if KEY == 'l' {
+ return (game::Command.ShootRight, none);
+ }
@@ -38,0 +46,9 @@
+}
+
+export (game::Command, error) read_command() {
+ string line;
+ inputError := stdin("command>", &line);
+ if inputError != none {
+ return (game::Command.Quit, none);
+ }
+ return parse(line.view());
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/src/input_test.fl tutorial/checkpoints/06-archery/src/input_test.fl
--- tutorial/checkpoints/05-goblin/src/input_test.fl
+++ tutorial/checkpoints/06-archery/src/input_test.fl
@@ -0,0 +1,18 @@
+module input_test;
+
+@imports
+ :: root::game
+ :: root::input
+ :: lib::std::tests::asserts
+ :: lib::std::tests::testing;
+
+test direct_key_diamonds_parse(&testing::UnitTest t) {
+ move, moveError := input::parse("W");
+ shot, shotError := input::parse("l");
+ asserts::is_true(
+ t,
+ moveError == none and move == game::Command.MoveUp and
+ shotError == none and shot == game::Command.ShootRight,
+ "WASD moves and IJKL shoots after one lowercase normalization"
+ );
+}
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/src/render.fl tutorial/checkpoints/06-archery/src/render.fl
--- tutorial/checkpoints/05-goblin/src/render.fl
+++ tutorial/checkpoints/06-archery/src/render.fl
@@ -18 +18 @@
- } else if position.equals(&goblin) {
+ } else if state->isGoblinAlive() and position.equals(&goblin) {
@@ -30,0 +31,2 @@
+ canvas.append(" Arrows: ") != none or
+ canvas.appendInteger(state->arrows()) != none or
@@ -44,0 +47,9 @@
+ if event == game::PlayerEvent.ShotHit {
+ return "Your arrow hits! The goblin falls.";
+ }
+ if event == game::PlayerEvent.ShotMiss {
+ return "Your arrow misses; a wall stops it.";
+ }
+ if event == game::PlayerEvent.NoArrows {
+ return "Your quiver is empty.";
+ }
@@ -46 +57 @@
- return "Move: W/A/S/D | H help | Q quit";
+ return "Move Shoot\n W I\nA S D J K L\nH help | Q quit";
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/tests/frame.svg tutorial/checkpoints/06-archery/tests/frame.svg
--- tutorial/checkpoints/05-goblin/tests/frame.svg
+++ tutorial/checkpoints/06-archery/tests/frame.svg
@@ -3 +3 @@
- <title>05-goblin representative frame</title>
+ <title>06-archery representative frame</title>
@@ -8,2 +8,2 @@
- <text x="24" y="75" class="terminal">#@.###..#</text>
- <text x="24" y="94" class="terminal">#g......#</text>
+ <text x="24" y="75" class="terminal">#..###..#</text>
+ <text x="24" y="94" class="terminal">#.......#</text>
@@ -11 +11 @@
- <text x="24" y="132" class="terminal">#&gt;......#</text>
+ <text x="24" y="132" class="terminal">#@......#</text>
@@ -13 +13 @@
- <text x="24" y="170" class="terminal">HP: 0 Turn: 10</text>
+ <text x="24" y="170" class="terminal">HP: 3 Arrows: 2 Turn: 5</text>
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/tests/frame.txt tutorial/checkpoints/06-archery/tests/frame.txt
--- tutorial/checkpoints/05-goblin/tests/frame.txt
+++ tutorial/checkpoints/06-archery/tests/frame.txt
@@ -3,2 +2,0 @@
-#@.###..#
-#g......#
@@ -6 +4,3 @@
-#>......#
+#.......#
+#..###..#
+#@......#
@@ -8 +8 @@
-HP: 0 Turn: 10
+HP: 3 Arrows: 2 Turn: 5
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/tests/input.txt tutorial/checkpoints/06-archery/tests/input.txt
--- tutorial/checkpoints/05-goblin/tests/input.txt
+++ tutorial/checkpoints/06-archery/tests/input.txt
@@ -1,6 +0,0 @@
-d
-a
-d
-a
-d
-a
@@ -8,0 +3 @@
+l
diff -rN -U0 --exclude target tutorial/checkpoints/05-goblin/tests/output.txt tutorial/checkpoints/06-archery/tests/output.txt
--- tutorial/checkpoints/05-goblin/tests/output.txt
+++ tutorial/checkpoints/06-archery/tests/output.txt
@@ -8 +8 @@
-HP: 3 Turn: 0
+HP: 3 Arrows: 3 Turn: 0
@@ -13,2 +13,2 @@
-#.@.....#
-#..###..#
+#.......#
+#@.###..#
@@ -19,12 +19 @@
-HP: 3 Turn: 1
-command>
-You move.
-The goblin moves closer.
-#########
-#@......#
-#..###..#
-#....g..#
-#..###..#
-#>......#
-#########
-HP: 3 Turn: 2
+HP: 3 Arrows: 3 Turn: 1
@@ -35,12 +24 @@
-#.@.....#
-#..###..#
-#...g...#
-#..###..#
-#>......#
-#########
-HP: 3 Turn: 3
-command>
-You move.
-The goblin moves closer.
-#########
-#@......#
+#.......#
@@ -48 +26 @@
-#..g....#
+#@...g..#
@@ -52 +30 @@
-HP: 3 Turn: 4
+HP: 3 Arrows: 3 Turn: 2
@@ -54,2 +32 @@
-You move.
-The goblin moves closer.
+Your arrow hits! The goblin falls.
@@ -57,3 +34 @@
-#.@.....#
-#..###..#
-#.g.....#
+#.......#
@@ -61,7 +35,0 @@
-#>......#
-#########
-HP: 3 Turn: 5
-command>
-You move.
-The goblin moves closer.
-#########
@@ -70,2 +37,0 @@
-#g......#
-#..###..#
@@ -74 +40 @@
-HP: 3 Turn: 6
+HP: 3 Arrows: 2 Turn: 3
@@ -77 +42,0 @@
-The goblin closes in! Move before its next turn.
@@ -80,2 +44,0 @@
-#@.###..#
-#g......#
@@ -83,7 +45,0 @@
-#>......#
-#########
-HP: 3 Turn: 7
-command>
-The goblin blocks that tile.
-The goblin attacks: -1 HP.
-#########
@@ -92,2 +47,0 @@
-#g......#
-#..###..#
@@ -96 +50 @@
-HP: 2 Turn: 8
+HP: 3 Arrows: 2 Turn: 4
@@ -98,2 +52 @@
-The goblin blocks that tile.
-The goblin attacks: -1 HP.
+You reach the exit.
@@ -102,2 +54,0 @@
-#@.###..#
-#g......#
@@ -105,7 +55,0 @@
-#>......#
-#########
-HP: 1 Turn: 9
-command>
-The goblin blocks that tile.
-The goblin attacks: -1 HP.
-#########
@@ -113,2 +56,0 @@
-#@.###..#
-#g......#
@@ -116 +58 @@
-#>......#
+#@......#
@@ -118,2 +60,2 @@
-HP: 0 Turn: 10
-The goblin caught you.
+HP: 3 Arrows: 2 Turn: 5
+You escaped Arrowfall!
Check, test, build, and run commands
Verification commands
longbow check tutorial/checkpoints/06-archery
longbow test tutorial/checkpoints/06-archery
longbow build tutorial/checkpoints/06-archery
longbow run tutorial/checkpoints/06-archery < tutorial/checkpoints/06-archery/tests/input.txt

Versions in this tutorial

  1. 1. Render the first room
  2. 2. Represent game state
  3. 3. Read movement commands
  4. 4. Add the map and collision
  5. 5. Add turns and the goblin
  6. 6. Add archery and line of sight

Next version

The game rules are complete, but pursuit ties always resolve the same way and interactive screen clearing is unsuitable for golden output. The final version addresses both constraints.

Add deterministic seeds and release checks

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.