Current source: curriculum/arrowfall/checkpoints/05-goblin · Flecha 0.3.0 · 5/7.
The program at this checkpoint
Previously: Checkpoint 04 has walls, an exit, turn counting, separated modules, and its first rule test.
Checkpoint 04 makes movement meaningful, but the optimal route never changes and health would have no purpose. Adding one goblin introduces pressure, provided the player can understand exactly when the enemy is allowed to move or attack.
This checkpoint defines that timing as engine data. One effective player turn permits one goblin response. Entering danger produces a warning; damage is possible only when the player began a later action already adjacent and failed to get away.
Current limitation
The dungeon is safe, so the shortest route is automatic and HP would mean nothing.
Required next behavior: Add fair pressure: a goblin responds once per effective turn, warns first, and attacks only after a later failed response.
Model and invariants
Return a TurnResult with player/enemy events and whether time advanced. The CLI renders engine facts instead of guessing.
TurnResult records every consequence of one command as one value. Private methods keep pursuit and damage rules inside Game, so the CLI only renders returned events.
TurnResult is the complete observable result of one command: a player event, an enemy event, and spentTurn. The CLI displays those facts but never recomputes adjacency, turn cost, or damage. Keeping policy in Game prevents terminal wording from becoming a second rules engine.
Game::apply captures DANGER_BEFORE before attempting the player action. This snapshot is the temporal contract. New adjacency created by the current action can warn, but it cannot retroactively authorize an attack in the same turn.
spentTurn distinguishes time from input. Help, Quit, and a safe collision do not advance the goblin. A blocked action while already in danger does spend time, because doing nothing under immediate threat is still the player response for that turn.
goblinTurn first considers an authorized attack, then current adjacency, then movement. That order enforces “warn before damage” and “move or attack, never both.” tryGoblinStep also refuses the player, walls, and the exit.
The pursuit chooses horizontal progress before vertical progress in this version. Variety is deliberately deferred; the final checkpoint changes tie order with a seed while preserving this turn contract.
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 goblin, HP, loss, and threat.
- Distinguish free no-ops from spent turns.
- Test warning before damage.
Events make a turn observable
TurnResult names the three pieces of information the caller needs after apply. The result is a value, not mutable shared state, so the renderer receives a coherent account of one command.
Excerpt: src/game.fl:47–80. The complete file appears in the source section below.
i32 x; i32 y;};
export structure TurnResult { PlayerEvent player; EnemyEvent enemy; bool spentTurn;};
export structure Game { private Position player; private Position goblin; private i32 playerHealth; 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;PlayerEvent and EnemyEvent are separate because both sides can act during one effective turn. Defaults represent the absence of an enemy event and a free action without requiring several constructors.
spentTurn is explicit even though Game also stores turnCount. The former explains this command to the caller; the latter records cumulative engine state. The CLI does not infer one from the other.
One command, one TurnResult
apply snapshots adjacency in DANGER_BEFORE before computing next. The branches then distinguish a safe no-op from the same no-op performed while the goblin was already adjacent.
Excerpt: src/game.fl:108–147. The complete file appears in the source section below.
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 = 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) { if !DANGER_BEFORE { return TurnResult::make(PlayerEvent.GoblinBlocks); } self->turnCount++; return TurnResult::make( PlayerEvent.GoblinBlocks, self->goblinTurn(true), true ); } self->player = next; self->turnCount++; if tile_at(self->player) == '>' { self->gameOutcome = Outcome.Won; return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true); } return TurnResult::make( PlayerEvent.Moved, self->goblinTurn(DANGER_BEFORE), true );}A safe wall or goblin collision returns with spentTurn false and does not call goblinTurn. Under prior danger, the same failed movement increments turnCount and calls goblinTurn(true), allowing the promised attack.
A successful move always spends a turn. Passing DANGER_BEFORE—not newly calculated adjacency—means approaching the goblin can produce Threatened but cannot lose HP until a later response. Reaching the exit ends the game before any enemy action.
Warn before attacking
goblinTurn receives attack permission from apply rather than deriving history from the current board. It can therefore distinguish “was already dangerous” from “became adjacent just now.”
Excerpt: src/game.fl:149–196. The complete file appears in the source section below.
private EnemyEvent Game::goblinTurn(&self, bool canAttack) { 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;}The attack branch runs only when canAttack and adjacency are both true. Otherwise, current adjacency returns Threatened without damage. Pursuit happens only when neither condition applies.
After one successful step, the method reports Threatened if it reached adjacency or Moved otherwise. It returns immediately and never falls through to attack, preserving one enemy consequence per turn.
The temporal contract is tested
The focused tests encode both sides of the timing contract: a wall is free while safe, and first adjacency warns before a later failed response causes damage.
Excerpt: src/game_test.fl:8–36. The complete file appears in the source section below.
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" );}safe_wall_is_free checks the returned player event, spentTurn, and cumulative turn together. The second test drives a deterministic approach, captures the warning result, then verifies that the following blocked action attacks exactly once.
These assertions test Game directly. Terminal output may change wording later without weakening the rule that warning precedes damage.
Run and inspect the result
The command uses the checkpoint’s recorded input, so the behavior described above can be reproduced without manual timing.
longbow run tutorial/checkpoints/05-goblin < tutorial/checkpoints/05-goblin/tests/input.txtRecorded standard input
dadadassssThe recorded route lets the goblin approach, exposes the warning at full health, and then demonstrates damage on later unresolved danger. The final representative frame is the deterministic defeat state at HP 0 and turn 10.
Reading the transcript should show no combined move-and-attack event. Safe failed actions leave the turn unchanged; the same failure after a warning consumes the response and may subtract one HP.
Accessible frame transcript
##########.......##@.###..##g......##..###..##>......##########HP: 0 Turn: 10Complete recorded stdout
##########@......##..###..##......g##..###..##>......##########HP: 3 Turn: 0command>You move.The goblin moves closer.##########.@.....##..###..##.....g.##..###..##>......##########HP: 3 Turn: 1command>You move.The goblin moves closer.##########@......##..###..##....g..##..###..##>......##########HP: 3 Turn: 2command>You move.The goblin moves closer.##########.@.....##..###..##...g...##..###..##>......##########HP: 3 Turn: 3command>You move.The goblin moves closer.##########@......##..###..##..g....##..###..##>......##########HP: 3 Turn: 4command>You move.The goblin moves closer.##########.@.....##..###..##.g.....##..###..##>......##########HP: 3 Turn: 5command>You move.The goblin moves closer.##########@......##..###..##g......##..###..##>......##########HP: 3 Turn: 6command>You move.The goblin closes in! Move before its next turn.##########.......##@.###..##g......##..###..##>......##########HP: 3 Turn: 7command>The goblin blocks that tile.The goblin attacks: -1 HP.##########.......##@.###..##g......##..###..##>......##########HP: 2 Turn: 8command>The goblin blocks that tile.The goblin attacks: -1 HP.##########.......##@.###..##g......##..###..##>......##########HP: 1 Turn: 9command>The goblin blocks that tile.The goblin attacks: -1 HP.##########.......##@.###..##g......##..###..##>......##########HP: 0 Turn: 10The goblin caught you.What this checkpoint establishes
Time is now an explicit engine contract. Game reports what each side did and whether time advanced, while the CLI remains a presentation layer that cannot invent rules.
The player can understand danger but can only flee. The next checkpoint reuses direction for a second action family: spending a limited arrow to trace a line of sight.
Exercise
The first adjacent frame stays at 3 HP. Only the next failed response prints -1 HP; the goblin never moves and attacks together.
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 (7 files)
longbow.toml
[package]name = "arrowfall-step-05"version = "0.1.0"kind = "program"flecha = ">=0.3.0, <0.4.0"
[dependencies]longbow.lock
{ "schema": 1, "root": "root", "packages": [ { "id": "root", "identity": "local/arrowfall_step_05", "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
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, Help, Quit}
export enum Outcome { default Playing, Won, Lost, Quit}
export enum PlayerEvent { default None, Moved, Wall, GoblinBlocks, 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 i32 playerHealth; 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.playerHealth = 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 = 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) { if !DANGER_BEFORE { return TurnResult::make(PlayerEvent.GoblinBlocks); } self->turnCount++; return TurnResult::make( PlayerEvent.GoblinBlocks, self->goblinTurn(true), true ); } self->player = next; self->turnCount++; if tile_at(self->player) == '>' { self->gameOutcome = Outcome.Won; return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true); } return TurnResult::make( PlayerEvent.Moved, self->goblinTurn(DANGER_BEFORE), true );}
private EnemyEvent Game::goblinTurn(&self, bool canAttack) { 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;}
i32 Game::health(&const self) { return self->playerHealth;}
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.Help => Direction.Up, Command.Quit => Direction.Up, };}
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
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
module input;
@imports :: root::game :: lib::std::text::{ascii, strings};
export error CommandError { InvalidCommand}
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()); 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 == 'h' { return (game::Command.Help, none); } if KEY == 'q' { return (game::Command.Quit, none); } return (none, CommandError.InvalidCommand);}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
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 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(" 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.Help { return "Move: W/A/S/D | H 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/04-map/README.md tutorial/checkpoints/05-goblin/README.md--- tutorial/checkpoints/04-map/README.md+++ tutorial/checkpoints/05-goblin/README.md@@ -1 +1 @@-# 04 — Walls make movement meaningful+# 05 — A turn now has consequences@@ -5,2 +5,2 @@-Checkpoint 03 moves on an open rectangle. Every in-bounds direction succeeds,-so movement has no spatial decision.+Checkpoint 04 has meaningful walls and an exit, but the room is safe and the+shortest path is automatic.@@ -10,2 +10,2 @@-The symbols introduced in checkpoint 01 should become rules. A real room needs-solid walls, a reachable exit, and feedback when an action cannot happen.+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.@@ -15,4 +15,4 @@-The fixed 9x7 map is stored as a checked slice over a fixed array. Walls reject-movement without spending a turn; entering `>` wins. Engine, input, and render-modules now separate rules from terminal I/O, and the first unit test protects-collision and victory.+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.@@ -35 +35 @@-Exact changes: `../../diffs/04-map.patch`.+Exact changes: `../../diffs/05-goblin.patch`.@@ -40,4 +40,4 @@-longbow check tutorial/checkpoints/04-map-longbow test tutorial/checkpoints/04-map-longbow build tutorial/checkpoints/04-map-longbow run tutorial/checkpoints/04-map < tutorial/checkpoints/04-map/tests/input.txt+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@@ -48,4 +48,5 @@-1. A room appears — static scene.-2. The picture becomes state — structs own position.-3. The player moves — stdin drives an open floor.-4. **Walls make movement meaningful** — map, collision, exit, modules, test.+1. A room appears.+2. The picture becomes state.+3. The player moves.+4. Walls create collision and victory.+5. **A turn has consequences** — pursuit, warning, HP, defeat, events.@@ -55,2 +56,2 @@-The recorded leading `W` must print the wall message while the next frame still-shows `Turn: 0`; four `S` moves must then win on turn 4.+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.diff -rN -U0 --exclude target tutorial/checkpoints/04-map/longbow.lock tutorial/checkpoints/05-goblin/longbow.lock--- tutorial/checkpoints/04-map/longbow.lock+++ tutorial/checkpoints/05-goblin/longbow.lock@@ -7 +7 @@- "identity": "local/arrowfall_step_04",+ "identity": "local/arrowfall_step_05",diff -rN -U0 --exclude target tutorial/checkpoints/04-map/longbow.toml tutorial/checkpoints/05-goblin/longbow.toml--- tutorial/checkpoints/04-map/longbow.toml+++ tutorial/checkpoints/05-goblin/longbow.toml@@ -2 +2 @@-name = "arrowfall-step-04"+name = "arrowfall-step-05"diff -rN -U0 --exclude target tutorial/checkpoints/04-map/src/game.fl tutorial/checkpoints/05-goblin/src/game.fl--- tutorial/checkpoints/04-map/src/game.fl+++ tutorial/checkpoints/05-goblin/src/game.fl@@ -17,0 +18 @@+ Help,@@ -23,0 +25 @@+ Lost,@@ -27,2 +29,3 @@-export enum Event {- default Moved,+export enum PlayerEvent {+ default None,+ Moved,@@ -29,0 +33,2 @@+ GoblinBlocks,+ Help,@@ -33,0 +39,7 @@+export enum EnemyEvent {+ default None,+ Moved,+ Threatened,+ Attacked+}+@@ -38,0 +51,6 @@+export structure TurnResult {+ PlayerEvent player;+ EnemyEvent enemy;+ bool spentTurn;+};+@@ -40,0 +59,2 @@+ private Position goblin;+ private i32 playerHealth;@@ -44,0 +65,12 @@+TurnResult TurnResult::make(+ PlayerEvent player,+ EnemyEvent enemy = EnemyEvent.None,+ bool spentTurn = false+) {+ return build TurnResult {+ self.player = player;+ self.enemy = enemy;+ self.spentTurn = spentTurn;+ };+}+@@ -68,0 +101,2 @@+ self.goblin = Position::at(7, 3);+ self.playerHealth = 3;@@ -74 +108,4 @@-Event Game::apply(&self, Command command) {+TurnResult Game::apply(&self, Command command) {+ if command == Command.Help {+ return TurnResult::make(PlayerEvent.Help);+ }@@ -77 +114 @@- return Event.Quit;+ return TurnResult::make(PlayerEvent.Quit);@@ -78,0 +116 @@+ const bool DANGER_BEFORE = adjacent(self->goblin, self->player);@@ -81 +119,16 @@- return Event.Wall;+ if !DANGER_BEFORE {+ return TurnResult::make(PlayerEvent.Wall);+ }+ self->turnCount++;+ return TurnResult::make(PlayerEvent.Wall, self->goblinTurn(true), true);+ }+ if next.equals(&self->goblin) {+ if !DANGER_BEFORE {+ return TurnResult::make(PlayerEvent.GoblinBlocks);+ }+ self->turnCount++;+ return TurnResult::make(+ PlayerEvent.GoblinBlocks,+ self->goblinTurn(true),+ true+ );@@ -87 +140 @@- return Event.Won;+ return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true);@@ -89 +142,54 @@- return Event.Moved;+ return TurnResult::make(+ PlayerEvent.Moved,+ self->goblinTurn(DANGER_BEFORE),+ true+ );+}++private EnemyEvent Game::goblinTurn(&self, bool canAttack) {+ 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;@@ -95,0 +202,8 @@+Position Game::goblinPosition(&const self) {+ return self->goblin;+}++i32 Game::health(&const self) {+ return self->playerHealth;+}+@@ -130,0 +245 @@+ Command.Help => Direction.Up,@@ -132,0 +248,7 @@+}++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));diff -rN -U0 --exclude target tutorial/checkpoints/04-map/src/game_test.fl tutorial/checkpoints/05-goblin/src/game_test.fl--- tutorial/checkpoints/04-map/src/game_test.fl+++ tutorial/checkpoints/05-goblin/src/game_test.fl@@ -8 +8 @@-test walls_are_free_and_the_exit_wins(&testing::UnitTest t) {+test safe_wall_is_free(&testing::UnitTest t) {@@ -11,4 +10,0 @@- state.apply(game::Command.MoveDown);- state.apply(game::Command.MoveDown);- state.apply(game::Command.MoveDown);- won := state.apply(game::Command.MoveDown);@@ -17,3 +13,22 @@- wall == game::Event.Wall and won == game::Event.Won and- state.turn() == 4 and state.outcome() == game::Outcome.Won,- "walls are free while entering the exit wins"+ 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"diff -rN -U0 --exclude target tutorial/checkpoints/04-map/src/input.fl tutorial/checkpoints/05-goblin/src/input.fl--- tutorial/checkpoints/04-map/src/input.fl+++ tutorial/checkpoints/05-goblin/src/input.fl@@ -31,0 +32,3 @@+ if KEY == 'h' {+ return (game::Command.Help, none);+ }diff -rN -U0 --exclude target tutorial/checkpoints/04-map/src/main.fl tutorial/checkpoints/05-goblin/src/main.fl--- tutorial/checkpoints/04-map/src/main.fl+++ tutorial/checkpoints/05-goblin/src/main.fl@@ -20,2 +20,6 @@- event := state.apply(command);- stdoutln(render::event_message(event));+ result := state.apply(command);+ stdoutln(render::player_message(result.player));+ enemyText := render::enemy_message(result.enemy);+ if enemyText.len() > 0 {+ stdoutln(enemyText);+ }@@ -28,0 +33,2 @@+ } else if state.outcome() == game::Outcome.Lost {+ stdoutln("The goblin caught you.");diff -rN -U0 --exclude target tutorial/checkpoints/04-map/src/render.fl tutorial/checkpoints/05-goblin/src/render.fl--- tutorial/checkpoints/04-map/src/render.fl+++ tutorial/checkpoints/05-goblin/src/render.fl@@ -10,0 +11 @@+ goblin := state->goblinPosition();@@ -16,0 +18,2 @@+ } else if position.equals(&goblin) {+ tile = 'g';@@ -26 +29,3 @@- if canvas.append("Turn: ") != none or+ if canvas.append("HP: ") != none or+ canvas.appendInteger(state->health()) != none or+ canvas.append(" Turn: ") != none or@@ -33,2 +38,2 @@-export stringv event_message(game::Event event) {- if event == game::Event.Wall {+export stringv player_message(game::PlayerEvent event) {+ if event == game::PlayerEvent.Wall {@@ -37 +42,7 @@- if event == game::Event.Won {+ if event == game::PlayerEvent.GoblinBlocks {+ return "The goblin blocks that tile.";+ }+ if event == game::PlayerEvent.Help {+ return "Move: W/A/S/D | H help | Q quit";+ }+ if event == game::PlayerEvent.Won {@@ -40 +51 @@- if event == game::Event.Quit {+ if event == game::PlayerEvent.Quit {@@ -43,0 +55,13 @@+}++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 "";diff -rN -U0 --exclude target tutorial/checkpoints/04-map/tests/frame.svg tutorial/checkpoints/05-goblin/tests/frame.svg--- tutorial/checkpoints/04-map/tests/frame.svg+++ tutorial/checkpoints/05-goblin/tests/frame.svg@@ -3 +3 @@- <title>04-map representative frame</title>+ <title>05-goblin representative frame</title>@@ -8,2 +8,2 @@- <text x="24" y="75" class="terminal">#..###..#</text>- <text x="24" y="94" class="terminal">#.......#</text>+ <text x="24" y="75" class="terminal">#@.###..#</text>+ <text x="24" y="94" class="terminal">#g......#</text>@@ -11 +11 @@- <text x="24" y="132" class="terminal">#@......#</text>+ <text x="24" y="132" class="terminal">#>......#</text>@@ -13 +13 @@- <text x="24" y="170" class="terminal">Turn: 4</text>+ <text x="24" y="170" class="terminal">HP: 0 Turn: 10</text>diff -rN -U0 --exclude target tutorial/checkpoints/04-map/tests/frame.txt tutorial/checkpoints/05-goblin/tests/frame.txt--- tutorial/checkpoints/04-map/tests/frame.txt+++ tutorial/checkpoints/05-goblin/tests/frame.txt@@ -2,0 +3,2 @@+#@.###..#+#g......#@@ -4,3 +6 @@-#.......#-#..###..#-#@......#+#>......#@@ -8 +8 @@-Turn: 4+HP: 0 Turn: 10diff -rN -U0 --exclude target tutorial/checkpoints/04-map/tests/input.txt tutorial/checkpoints/05-goblin/tests/input.txt--- tutorial/checkpoints/04-map/tests/input.txt+++ tutorial/checkpoints/05-goblin/tests/input.txt@@ -1 +1,6 @@-w+d+a+d+a+d+adiff -rN -U0 --exclude target tutorial/checkpoints/04-map/tests/output.txt tutorial/checkpoints/05-goblin/tests/output.txt--- tutorial/checkpoints/04-map/tests/output.txt+++ tutorial/checkpoints/05-goblin/tests/output.txt@@ -4 +4 @@-#.......#+#......g#@@ -8 +8 @@-Turn: 0+HP: 3 Turn: 0@@ -10 +10,13 @@-A wall blocks your way. No turn spent.+You move.+The goblin moves closer.+#########+#.@.....#+#..###..#+#.....g.#+#..###..#+#>......#+#########+HP: 3 Turn: 1+command>+You move.+The goblin moves closer.@@ -14 +26 @@-#.......#+#....g..#@@ -18 +30 @@-Turn: 0+HP: 3 Turn: 2@@ -20,0 +33 @@+The goblin moves closer.@@ -22,3 +35,3 @@-#.......#-#@.###..#-#.......#+#.@.....#+#..###..#+#...g...#@@ -28 +41 @@-Turn: 1+HP: 3 Turn: 3@@ -30,0 +44 @@+The goblin moves closer.@@ -32 +46,14 @@-#.......#+#@......#+#..###..#+#..g....#+#..###..#+#>......#+#########+HP: 3 Turn: 4+command>+You move.+The goblin moves closer.+#########+#.@.....#+#..###..#+#.g.....#@@ -33,0 +61,7 @@+#>......#+#########+HP: 3 Turn: 5+command>+You move.+The goblin moves closer.+#########@@ -35,0 +70,2 @@+#g......#+#..###..#@@ -38 +74 @@-Turn: 2+HP: 3 Turn: 6@@ -40,0 +77 @@+The goblin closes in! Move before its next turn.@@ -42,0 +80,2 @@+#@.###..#+#g......#@@ -43,0 +83,7 @@+#>......#+#########+HP: 3 Turn: 7+command>+The goblin blocks that tile.+The goblin attacks: -1 HP.+#########@@ -45,0 +92,2 @@+#g......#+#..###..#@@ -48 +96 @@-Turn: 3+HP: 2 Turn: 8@@ -50 +98,2 @@-You reach the exit.+The goblin blocks that tile.+The goblin attacks: -1 HP.@@ -52,0 +102,2 @@+#@.###..#+#g......#@@ -53,0 +105,7 @@+#>......#+#########+HP: 1 Turn: 9+command>+The goblin blocks that tile.+The goblin attacks: -1 HP.+#########@@ -54,0 +113,2 @@+#@.###..#+#g......#@@ -56 +116 @@-#@......#+#>......#@@ -58,2 +118,2 @@-Turn: 4-You escaped Arrowfall!+HP: 0 Turn: 10+The goblin caught you.Check, test, build, and run commands
longbow check tutorial/checkpoints/05-goblinlongbow test tutorial/checkpoints/05-goblinlongbow build tutorial/checkpoints/05-goblinlongbow run tutorial/checkpoints/05-goblin < tutorial/checkpoints/05-goblin/tests/input.txtVersions in this tutorial
- 1. Render the first room
- 2. Represent game state
- 3. Read movement commands
- 4. Add the map and collision
- 5. Add turns and the goblin
Next version
The goblin can now damage the player, but movement is the only response. The next version adds directional shots and a limited arrow count.
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.