diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,83 @@
 # Revision history for swarm
 
+## **0.3.0.0** - 2023-01-30
+
+This is the first release of 2023! It contains:
+- various new features and bugfixes (see subsections below)
+- UI tweaks
+- fewer typos (thanks @bwignall and @kostmo)
+- upgraded dependencies (`>=lsp-1.6` and `>=brick-1.5`) and raised Stack resolver to use GHC 9.4 [#1001](https://github.com/swarm-game/swarm/pull/1001)
+- a ton of internal refactoring
+
+### Fixed bugs
+* copy parent robot context to child when executing `build` by @byorgey in [#817](https://github.com/swarm-game/swarm/pull/817)
+* Merge new requirements context with existing when running from REPL by @byorgey in [#965](https://github.com/swarm-game/swarm/pull/965)
+* Reset `lastFrameTime` when starting a new scenario by @byorgey in [#855](https://github.com/swarm-game/swarm/pull/855)
+* Fix capability checking, and refactor/add lots of comments by @byorgey in [#959](https://github.com/swarm-game/swarm/pull/959)
+* Fix `EntityMap` merging by @byorgey in [#962](https://github.com/swarm-game/swarm/pull/962)
+* Fix: add first heard message to log by @xsebek in [#842](https://github.com/swarm-game/swarm/pull/842)
+* Fix hypothetical result of condition by @xsebek in [#940](https://github.com/swarm-game/swarm/pull/940)
+* One file per achievement record by @kostmo in [#954](https://github.com/swarm-game/swarm/pull/954)
+* Show duplicate robot logs by @xsebek in [#1022](https://github.com/swarm-game/swarm/pull/1022)
+* Copy requirements map to robot context when loading a new `ProcessedTerm` by @byorgey in [#827](https://github.com/swarm-game/swarm/pull/827)
+* Set REPL to `Working` when base has `program` field specified by @byorgey in [#846](https://github.com/swarm-game/swarm/pull/846)
+* Create swarm subdirectories as necessary by @xsebek in [#943](https://github.com/swarm-game/swarm/pull/943)
+
+### New Features
+
+#### Swarm language
+* implement Equip/Unequip by @kostmo in [#887](https://github.com/swarm-game/swarm/pull/887)
+  * The great `install`/`equip` switch by @byorgey in [#989](https://github.com/swarm-game/swarm/pull/989)
+* Meeting other robots by @byorgey in [#920](https://github.com/swarm-game/swarm/pull/920)
+* New `heading` command by @byorgey in [#955](https://github.com/swarm-game/swarm/pull/955)
+* Add `isempty : cmd bool` to check whether current cell has an entity by @byorgey in [#968](https://github.com/swarm-game/swarm/pull/968)
+
+#### Entities
+* Add `GPS receiver` device to provide `senseloc` capability by @byorgey in [#956](https://github.com/swarm-game/swarm/pull/956)
+* Device with recipe for 'appear' capability by @kostmo in [#1027](https://github.com/swarm-game/swarm/pull/1027)
+* device for setname by @kostmo in [#1028](https://github.com/swarm-game/swarm/pull/1028)
+
+#### LSP improvements
+* swarm-lang IDE hover by @kostmo in [#972](https://github.com/swarm-game/swarm/pull/972)
+* Include `SrcLoc` info with variable binding sites by @byorgey in [#993](https://github.com/swarm-game/swarm/pull/993)
+* Annotate ASTs with types at every node by @byorgey in [#991](https://github.com/swarm-game/swarm/pull/991)
+* Improve OnHover for Def/Let, App and Const by @xsebek in [#986](https://github.com/swarm-game/swarm/pull/986)
+* warn of unused variables by @kostmo in [#983](https://github.com/swarm-game/swarm/pull/983)
+
+#### Web API
+* Expose REPL history via web interface by @kostmo in [#821](https://github.com/swarm-game/swarm/pull/821)
+
+#### Command line options
+* Add full color mode option by @xsebek in [#851](https://github.com/swarm-game/swarm/pull/851)
+
+#### New scenarios
+* New ranching scenario by @kostmo in [#835](https://github.com/swarm-game/swarm/pull/835)
+* new bind tutorial by @kostmo in [#895](https://github.com/swarm-game/swarm/pull/895)
+* A "bucket brigade" of robots by @kostmo in [#885](https://github.com/swarm-game/swarm/pull/885)
+* Ice Cream Shop by @kostmo in [#1008](https://github.com/swarm-game/swarm/pull/1008)
+* word search by @kostmo in [#999](https://github.com/swarm-game/swarm/pull/999)
+* Wolf, Goat, Cabbage scenario by @kostmo in [#944](https://github.com/swarm-game/swarm/pull/944)
+* swarm logo animation by @kostmo in [#1018](https://github.com/swarm-game/swarm/pull/1018)
+
+#### Scenario development
+* Boolean expressions of objective prerequisites by @kostmo in [#927](https://github.com/swarm-game/swarm/pull/927)
+* support 'orientationMap' by @kostmo in [#1023](https://github.com/swarm-game/swarm/pull/1023)
+* support custom attributes defined in scenarios by @kostmo in [#1058](https://github.com/swarm-game/swarm/pull/1058)
+
+#### UI enhancements
+* show devices that enable commands by @kostmo in [#899](https://github.com/swarm-game/swarm/pull/899)
+* Add a "driving mode" to the REPL by @kostmo in [#819](https://github.com/swarm-game/swarm/pull/819)
+
+#### Achievements
+* achievements board by @kostmo in [#796](https://github.com/swarm-game/swarm/pull/796)
+* first tutorial completion achievement by @kostmo in [#925](https://github.com/swarm-game/swarm/pull/925)
+* Add achievement for losing by @kostmo in [#945](https://github.com/swarm-game/swarm/pull/945)
+
+#### Debugging
+* Better CESK machine pretty-printing by @byorgey in [#948](https://github.com/swarm-game/swarm/pull/948)
+* Add the full expected location for the data directory by @TristanCacqueray in [#908](https://github.com/swarm-game/swarm/pull/908)
+* Extend logs for warnings by @xsebek in [#982](https://github.com/swarm-game/swarm/pull/982)
+
 ## **0.2.0.0** - 2022-11-01
 
 A bunch of small fixes and improvements; special thanks to new
diff --git a/app/Main.hs b/app/Main.hs
--- a/app/Main.hs
+++ b/app/Main.hs
@@ -1,3 +1,4 @@
+{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TemplateHaskell #-}
 
@@ -14,11 +15,12 @@
 import Swarm.DocGen (EditorType (..), GenerateDocs (..), PageAddress (..), SheetType (..), generateDocs)
 import Swarm.Language.LSP (lspMain)
 import Swarm.Language.Pipeline (processTerm)
-import Swarm.TUI.Model (AppOpts (..))
+import Swarm.TUI.Model (AppOpts (..), ColorMode (..))
 import Swarm.Version
 import Swarm.Web (defaultPort)
 import System.Exit (exitFailure, exitSuccess)
 import System.IO (hPrint, stderr)
+import Text.Read (readMaybe)
 
 gitInfo :: Maybe GitInfo
 gitInfo = either (const Nothing) Just ($$tGitInfoCwdTry)
@@ -45,7 +47,17 @@
         , command "version" (info (pure Version) (progDesc "Get current and upstream version."))
         ]
     )
-    <|> Run <$> (AppOpts <$> seed <*> scenario <*> run <*> autoplay <*> cheat <*> webPort <*> pure gitInfo)
+    <|> Run
+      <$> ( AppOpts
+              <$> seed
+              <*> scenario
+              <*> run
+              <*> autoplay
+              <*> cheat
+              <*> color
+              <*> webPort
+              <*> pure gitInfo
+          )
  where
   format :: Parser CLI
   format =
@@ -98,13 +110,24 @@
             <> help ("Set the web service port (or disable it with 0). Default to " <> show defaultPort <> ".")
         )
   scenario :: Parser (Maybe String)
-  scenario = optional $ strOption (long "scenario" <> short 'c' <> metavar "FILE" <> help "Name of a scenario to load")
+  scenario = optional $ strOption (long "scenario" <> short 'i' <> metavar "FILE" <> help "Name of an input scenario to load")
   run :: Parser (Maybe String)
   run = optional $ strOption (long "run" <> short 'r' <> metavar "FILE" <> help "Run the commands in a file at startup")
   autoplay :: Parser Bool
   autoplay = switch (long "autoplay" <> short 'a' <> help "Automatically run the solution defined in the scenario, if there is one. Mutually exclusive with --run.")
   cheat :: Parser Bool
   cheat = switch (long "cheat" <> short 'x' <> help "Enable cheat mode. This allows toggling Creative Mode with Ctrl+v and unlocks \"Testing\" scenarios in the menu.")
+  color :: Parser (Maybe ColorMode)
+  color = optional $ option colorModeParser (long "color" <> short 'c' <> metavar "MODE" <> help "Use none/8/16/full color mode.")
+  colorModeParser =
+    Data.Foldable.asum
+      [ ColorMode8 <$ text "8"
+      , ColorMode16 <$ text "16"
+      , ColorMode240 <$> maybeReader (\case ('2' : '4' : '0' : '_' : w) -> readMaybe w; _ -> Nothing)
+      , FullColor <$ text "full"
+      , NoColor <$ text "none"
+      ]
+  text t = maybeReader (\x -> if x == t then Just x else Nothing)
 
 cliInfo :: ParserInfo CLI
 cliInfo =
diff --git a/bench/Benchmark.hs b/bench/Benchmark.hs
--- a/bench/Benchmark.hs
+++ b/bench/Benchmark.hs
@@ -9,8 +9,6 @@
 import Control.Monad.State (evalStateT, execStateT)
 import Criterion.Main (Benchmark, bench, bgroup, defaultConfig, defaultMainWith, whnfAppIO)
 import Criterion.Types (Config (timeLimit))
-import Data.Int (Int64)
-import Linear.V2 (V2 (V2))
 import Swarm.Game.CESK (emptyStore, initMachine)
 import Swarm.Game.Display (defaultRobotDisplay)
 import Swarm.Game.Robot (TRobot, mkRobot)
@@ -22,13 +20,14 @@
 import Swarm.Language.Pipeline (ProcessedTerm)
 import Swarm.Language.Pipeline.QQ (tmQ)
 import Swarm.Language.Syntax (north)
+import Swarm.Util.Location
 
 -- | The program of a robot that does nothing.
 idleProgram :: ProcessedTerm
 idleProgram = [tmQ| {} |]
 
 -- | The program of a robot which waits a random number of ticks, changes its
---   appearence, then waits another random number of ticks, places a tree, and
+--   appearance, then waits another random number of ticks, places a tree, and
 --   then self-destructs.
 treeProgram :: ProcessedTerm
 treeProgram =
@@ -70,14 +69,14 @@
   |]
 
 -- | Initializes a robot with program prog at location loc facing north.
-initRobot :: ProcessedTerm -> V2 Int64 -> TRobot
-initRobot prog loc = mkRobot () Nothing "" [] (Just north) loc defaultRobotDisplay (initMachine prog Context.empty emptyStore) [] [] False False 0
+initRobot :: ProcessedTerm -> Location -> TRobot
+initRobot prog loc = mkRobot () Nothing "" [] (Just loc) north defaultRobotDisplay (initMachine prog Context.empty emptyStore) [] [] False False 0
 
 -- | Creates a GameState with numRobot copies of robot on a blank map, aligned
 --   in a row starting at (0,0) and spreading east.
-mkGameState :: (V2 Int64 -> TRobot) -> Int -> IO GameState
+mkGameState :: (Location -> TRobot) -> Int -> IO GameState
 mkGameState robotMaker numRobots = do
-  let robots = [robotMaker (V2 (fromIntegral x) 0) | x <- [0 .. numRobots - 1]]
+  let robots = [robotMaker (Location (fromIntegral x) 0) | x <- [0 .. numRobots - 1]]
   Right initState <- runExceptT classicGame0
   execStateT
     (mapM addTRobot robots)
diff --git a/data/entities.yaml b/data/entities.yaml
--- a/data/entities.yaml
+++ b/data/entities.yaml
@@ -74,6 +74,16 @@
     Can be used to build a simple furnace, among other things.
   properties: [portable]
 
+- name: handle
+  display:
+    attr: device
+    char: 'h'
+  description:
+  - Ergonomic affordance for manipulating objects. Also grants the "setname" capability, allowing one
+    to assign a "handle" to robots.
+  properties: [portable]
+  capabilities: [setname]
+
 - name: lodestone
   display:
     attr: iron
@@ -84,6 +94,15 @@
   properties: [portable]
   capabilities: [negation]
 
+- name: beaglepuss
+  display:
+    attr: rubber
+    char: 'B'
+  description:
+  - Iconic novelty disguise. Renders one either completely inconspicuous, or the opposite.
+  properties: [portable]
+  capabilities: [appear]
+
 - name: boulder
   display:
     attr: rock
@@ -106,7 +125,7 @@
     char: 'Å'
     priority: 11
   description:
-  - A tunnel in a mountain through which robots can freely move.
+  - A tunnel in a mountain through which anything can freely move.
   properties: []
 
 - name: copper ore
@@ -326,15 +345,43 @@
     information, made of twisted cotton fibers.  Multiple strings can
     also be woven into larger configurations such as cloth or nets.
   - |
-    A string device enables two commands: the first, `format : a ->
-    text`, can turn any value into a suitable text representation.
-    The second is the infix operator `++ : text -> text -> text`
-    which can be used to concatenate two text values.  For example,
+    An equipped `string` device enables several commands for working with
+    `text` values:
   - |
+    `format : a -> text` can turn any value into a suitable text
+    representation.
+  - |
+    The infix operator `++ : text -> text -> text`
+    can be used to concatenate two text values.  For example,
+  - |
     "Number of widgets: " ++ format numWidgets
+  - |
+    `chars : text -> int` computes the number of characters in a
+    `text` value.
+  - |
+    `split : int -> text -> text * text` splits a `text` value into
+    two pieces, one before the given index and one after.
   properties: [portable]
   capabilities: [text]
 
+- name: decoder ring
+  display:
+    attr: silver
+    char: 'O'
+  description:
+  - |
+    A handy lookup table for converting from characters to numeric codes and
+    back, shaped for some reason into a ring.  When equipped, it
+    enables two functions:
+  - |
+    `charAt : int -> text -> int` returns the numeric code of the
+    character at a specific index in a (0-indexed) `text` value.
+  - |
+    `toChar : int -> text` creates a singleton (length-1) `text`
+    value containing a character with the given numeric code.
+  properties: [portable]
+  capabilities: [code]
+
 - name: lambda
   display:
     attr: flower
@@ -379,11 +426,11 @@
     attr: wood
     char: 'B'
   description:
-  - A robot with a boat installed can float on top of water without drowning.
+  - A robot with a boat equipped can float on top of water without drowning.
   - |
-    Note: most devices are automatically installed on robots that
+    Note: most devices are automatically equipped on robots that
     will require them; but this doesn't work in the case of boats since floating is not
-    associated with any particular command.  To manually ensure a boat is installed on
+    associated with any particular command.  To manually ensure a boat is equipped on
     a robot, just add the special command `require "boat"` to the robot's program.
   properties: [portable]
   capabilities: [float]
@@ -645,7 +692,7 @@
     attr: device
     char: '%'
   description:
-  - Installing treads on a robot allows it to move and turn.
+  - Equipping treads on a robot allows it to move and turn.
   - The `move` command moves the robot forward one unit.
   - 'Example:'
   - '  move; move; // move two units'
@@ -673,8 +720,8 @@
     char: '<'
   description:
   - A grabber arm is an all-purpose, hydraulically controlled device that can
-    manipulate other items and robots via the `grab`, `place`, `give`,
-    and `install` commands.
+    manipulate other items and robots via the `grab`, `place`, and `give`
+    commands.
   - The `grab` command takes no arguments; it simply grabs whatever is
     available, and also returns the name of the grabbed thing as a string.
     It raises an exception if run in a cell that does not contain an item.
@@ -682,13 +729,10 @@
     place.  The item is removed from the robot's inventory and placed
     in the robot's current cell (which must be empty).  Raises an
     exception if the operation fails.
-  - "The `give` command takes two arguments: the robot to
+  - "The `give` command takes two arguments: the actor to
     give an item to (which can be at most 1 cell away), and the name
     of the item to give. Raises an exception if the operation fails."
-  - "The `install` command takes two arguments: the robot
-    on which to install a device (which can be at most 1 cell away),
-    and the name of the device to install."
-  capabilities: [grab, give, place, install]
+  capabilities: [grab, give, place]
   properties: [portable]
 
 - name: fast grabber
@@ -697,7 +741,7 @@
     char: '≪'
   description:
   - A fast grabber is an improved version of the basic grabber - not only
-    can it 'grab', 'place', 'give', and 'install', it can also 'swap'.
+    can it 'grab', 'place', and 'give', it can also 'swap'.
   - The 'swap' command allows the robot to execute grab and place at the
     same time so that the location where the robot is standing does not
     become empty.
@@ -706,9 +750,26 @@
   - In addition you retain the capability to use the 'atomic' command,
     with which you can implement other commands that are safe when
     run in parallel.
-  capabilities: [grab, swap, give, place, install, atomic]
+  capabilities: [grab, swap, give, place, atomic]
   properties: [portable]
 
+- name: welder
+  display:
+    attr: device
+    char: 'f'
+  description:
+  - |
+    A welder can be used to affix other gadgets to one's robot, providing
+    the `equip` and `unequip` commands.
+  - |
+    The `equip` command takes one argument: the name of the device to equip.
+    Raises an exception if the device is not in your inventory.
+  - |
+    The `unequip` command takes one argument: the name of the device to unequip.
+    Raises an exception if the device is not equipped.
+  capabilities: [equip, unequip]
+  properties: [portable]
+
 - name: harvester
   display:
     attr: device
@@ -869,9 +930,13 @@
     about their surroundings.  Simply give `scan` a direction in which to scan,
     and information about the scanned item (if any) will be added to the robot's
     inventory."
-  - "A scanner also enables the `blocked` command, which returns a
+  - "A scanner also enables `blocked : cmd bool`, which returns a
      boolean value indicating whether the robot's path is blocked
-     (i.e. whether executing a `move` command would fail)."
+     (i.e. whether executing a `move` command would fail);
+     `ishere : text -> cmd bool` for checking whether the current
+     cell contains a particular entity; and `isempty : cmd bool` for
+     checking whether the current cell is empty of entities. Note that
+     `ishere` and `isempty` do not detect robots, only entities."
   - "Finally, robots can use the `upload` command to copy their accumulated
     knowledge to another nearby robot; for example, `upload base`."
   properties: [portable]
@@ -921,10 +986,10 @@
     attr: device
     char: '@'
   description:
-  - "Allows a robot to hear what nearby robots are saying."
-  - "Simply having this device installed will automatically
-     add messages said by nearby robots to this robot's log,
-     assuming it has a logger installed."
+  - "Allows a robot to hear anything being said nearby."
+  - "Simply having this device equipped will automatically
+     add messages said by nearby actors to this robot's log,
+     assuming it has a logger equipped."
   - "That way you can view any heard message later either in
      the logger or the message window."
   - "To wait for a message and get the string value, use:"
@@ -996,9 +1061,16 @@
     attr: device
     char: 'N'
   description:
-  - "A compass gives a robot the ability to orient using cardinal directions: north, south, west, and east."
-  - "Example:"
-  - "turn west; move; turn north"
+  - |
+    A compass gives a robot the ability to orient using the cardinal
+    directions north, south, west, and east; for example, `turn west;
+    move; turn north`.
+  - |
+    It also enables the `heading : cmd dir` command, which returns the
+    robot's current heading. For example, the following code moves
+    east and then restores the same heading as before:
+  - |
+    d <- heading; turn east; move; turn d
   properties: [portable]
   capabilities: [orient]
 
@@ -1031,7 +1103,7 @@
     attr: device
     char: 'Ю'
   description:
-  - An I/O cable can be used to communicate with an adjacent robot.
+  - An I/O cable can be used to communicate with adjacent actors.
   properties: [portable]
 
 - name: rubber band
@@ -1048,7 +1120,7 @@
   - "This seems like a safe way to execute `grab` only when there is a
     rock to grab.  However, it is actually possible for the `grab` to
     fail, if some other robot B snatches the rock right after robot A sensed
-    it and before robot A got around to grab it on the next game tick."
+    it and before robot A got around to grabbing it on the next game tick."
   - "This will make robot A very sad and it will crash."
   - "To prevent this situation, robot A can wrap the commands in `atomic`, like so:"
   - |
@@ -1063,9 +1135,46 @@
     char: '#'
   description:
   - A net is a device woven out of many strings.  With a net
-    installed, you can use the `try` command to catch errors. For example,
+    equipped, you can use the `try` command to catch errors. For example,
   - |
     `try {move} {turn left}`
   - will attempt to move, but if that fails, turn left instead.
   properties: [portable]
   capabilities: [try]
+
+- name: antenna
+  display:
+    attr: silver
+    char: 'Y'
+  description:
+  - A device for transforming electric current into radio waves, and
+    vice versa.
+  - |
+    It can be used to locate nearby actors, by bouncing radio
+    waves off them and listening for the echo.  This capability can be
+    accessed via two commands:
+  - |
+    `meet : cmd (() + actor)` tries to locate a
+    nearby actor (a robot, or... something else?) up to one cell away.
+    It returns a reference to the nearest actor, or a unit value if
+    none are found.
+  - |
+    `meetAll : (b -> actor -> cmd b) -> b -> cmd b` runs a command on
+    every nearby actor (other than oneself), folding over the results
+    to compute a final result of type `b`.  For example, if `x`, `y`,
+    and `z` are nearby actors, then `meetAll f b0` is equivalent to
+    `b1 <- f b0 x; b2 <- f b1 y; f b2 z`.
+  properties: [portable]
+  capabilities: [meet]
+
+- name: GPS receiver
+  display:
+    attr: device
+    char: 'G'
+  description:
+  - |
+    A GPS receiver triangulates your current (x,y) coordinates from
+    some convenient satellite signals,
+    enabling the command `whereami : cmd (int * int)`.
+  properties: [portable]
+  capabilities: [senseloc]
diff --git a/data/recipes.yaml b/data/recipes.yaml
--- a/data/recipes.yaml
+++ b/data/recipes.yaml
@@ -459,6 +459,8 @@
   - [4, big motor]
   - [64, iron plate]
   - [16, rubber]
+  required:
+  - [1, welder]
   out:
   - [1, tank treads]
 
@@ -478,6 +480,21 @@
   - [1, fast grabber]
 
 - in:
+  - [2, copper wire]
+  - [1, copper pipe]
+  - [1, iron plate]
+  - [1, I/O cable]
+  out:
+  - [1, welder]
+
+- in:
+  - [1, copper pipe]
+  out:
+  - [1, handle]
+  required:
+  - [1, welder]
+
+- in:
   - [4, circuit]
   - [1, iron plate]
   - [3, copper pipe]
@@ -508,6 +525,20 @@
   out:
   - [1, bitcoin]
 
+- in:
+  - [2, silver]
+  - [2, copper wire]
+  out:
+  - [1, antenna]
+
+- in:
+  - [1, antenna]
+  - [1, clock]
+  - [1, circuit]
+  - [1, compass]
+  out:
+  - [1, GPS receiver]
+
 ## MAGIC
 
 - in:
@@ -703,6 +734,13 @@
   - [1, strange loop]
   out:
   - [1, rubber band]
+
+- in:
+  - [1, rubber]
+  - [1, glass]
+  - [1, string]
+  out:
+  - [1, beaglepuss]
 
 #########################################
 ##                COTTON               ##
diff --git a/data/scenarios/Challenges/00-ORDER.txt b/data/scenarios/Challenges/00-ORDER.txt
--- a/data/scenarios/Challenges/00-ORDER.txt
+++ b/data/scenarios/Challenges/00-ORDER.txt
@@ -1,5 +1,11 @@
 chess_horse.yaml
 teleport.yaml
 2048.yaml
+word-search.yaml
+ice-cream.yaml
 hanoi.yaml
+bucket-brigade.yaml
+wolf-goat-cabbage.yaml
+friend.yaml
 Mazes
+Ranching
diff --git a/data/scenarios/Challenges/Mazes/easy_cave_maze.yaml b/data/scenarios/Challenges/Mazes/easy_cave_maze.yaml
--- a/data/scenarios/Challenges/Mazes/easy_cave_maze.yaml
+++ b/data/scenarios/Challenges/Mazes/easy_cave_maze.yaml
@@ -15,7 +15,6 @@
   def until = \p. \c. b <- p; if b {} {c; until p c} end;
   def fwd = until blocked move end;
   build {
-    require "branch predictor"; // #540
     turn east;
     until (ishere "goal") (fwd; turn right; fwd; turn left);
     grab
diff --git a/data/scenarios/Challenges/Mazes/easy_spiral_maze.yaml b/data/scenarios/Challenges/Mazes/easy_spiral_maze.yaml
--- a/data/scenarios/Challenges/Mazes/easy_spiral_maze.yaml
+++ b/data/scenarios/Challenges/Mazes/easy_spiral_maze.yaml
@@ -16,7 +16,6 @@
   def until = \p. \c. b <- p; if b {} {c; until p c} end;
   def fwd = until blocked move end;
   build {
-    require "branch predictor"; // #540
     turn west;
     until (ishere "goal") (fwd; turn right);
     grab
diff --git a/data/scenarios/Challenges/Mazes/invisible_maze.yaml b/data/scenarios/Challenges/Mazes/invisible_maze.yaml
--- a/data/scenarios/Challenges/Mazes/invisible_maze.yaml
+++ b/data/scenarios/Challenges/Mazes/invisible_maze.yaml
@@ -25,9 +25,6 @@
   def rightBlocked = tR; b <- blocked; tL; return b end;
   def mazeStep = ifC rightBlocked {ifC blocked {tL} {move}} {tR; move} end;
   build {
-    require "treads"; require "branch predictor"; require "lambda";
-    require "strange loop"; require "scanner"; require "grabber";   // #540
-
     forever (mazeStep; ifC (ishere "goal") {grab; return ()} {})
   }
 robots:
diff --git a/data/scenarios/Challenges/Mazes/loopy_maze_sol.sw b/data/scenarios/Challenges/Mazes/loopy_maze_sol.sw
--- a/data/scenarios/Challenges/Mazes/loopy_maze_sol.sw
+++ b/data/scenarios/Challenges/Mazes/loopy_maze_sol.sw
@@ -14,9 +14,5 @@
   tB; move; tB
 end;
 build {
-  require "treads"; require "scanner"; require "lambda";
-  require "strange loop"; require "branch predictor";
-  require "grabber";  // #540
-
   require 500 "rock"; DFS
 }
diff --git a/data/scenarios/Challenges/Ranching/00-ORDER.txt b/data/scenarios/Challenges/Ranching/00-ORDER.txt
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/Ranching/00-ORDER.txt
@@ -0,0 +1,1 @@
+gated-paddock.yaml
diff --git a/data/scenarios/Challenges/Ranching/_gated-paddock/enclosure-checking.sw b/data/scenarios/Challenges/Ranching/_gated-paddock/enclosure-checking.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/Ranching/_gated-paddock/enclosure-checking.sw
@@ -0,0 +1,255 @@
+// Algorithm:
+// ----------
+// Maintain current direction until a wall is encountered.
+// Then enter "wall-following mode".
+// This mode presumes the wall is not a loop.
+// Wall-following mode exploits recursion to keep track of how many left turns were made
+// and then unwinds them again by ensuring each is paired with a right turn.
+// Once the recursion is fully unwound, the robot proceeds along its original direction
+// (though it may now be laterally displaced).
+//
+// (If it was a loop, then an "oriented breadcrumb" would need to be left.
+// The breadcrumb is oriented in case a single-width passage is backtracked
+// along the opposite wall.)
+
+/** A "gate" is walkable, so we need to supplement the "blocked" check with this function.
+Since fences are "unwalkable", they do not need to be mentioned in this function.
+*/
+def isFenced =
+    s <- scan forward;
+    return (
+        case s
+            (\_. false)
+            (\x. x == "gate")
+    );
+    end;
+
+def isBlockedOrFenced =
+    b <- blocked;
+    f <- isFenced;
+    return (b || f);
+    end;
+
+// Returns true if we've already placed two
+// breadcrumbs on a given tile, false otherwise.
+def leaveBreadcrumbs =
+    
+    let bc1 = "fresh breadcrumb" in
+    let bc2 = "treaded breadcrumb" in
+
+    wasTraversedOnce <- ishere bc1;
+    if wasTraversedOnce {
+        _crumb <- grab;
+        make bc2;
+        place bc2;
+        return false;
+    } {
+        wasTraversedTwice <- ishere bc2;
+        if wasTraversedTwice {
+            return true;
+        } {
+            // Make sure nothing's in the way before we place
+            // our breadcrumb:
+            x <- scan down;
+            case x return (\y.
+                // If we're on a water tile, get rid of
+                // it with our special "drilling" recipe
+                if (y == "water") {
+                    drill down;
+                    // Nothing will remain on the ground.
+                    // after making the "steam" via
+                    // the drilling recipe.
+                } {
+                    grab;
+                    return ();
+                };
+            );
+
+            make bc1;
+            place bc1;
+            return false;
+        };
+    };
+    end;
+
+def goForwardToPatrol = \wasBlocked.
+    b <- isBlockedOrFenced;
+    if b {
+        turn left;
+        goForwardToPatrol true;
+        turn right;
+        goForwardToPatrol false;
+    } {
+        if wasBlocked {
+            isLoop <- leaveBreadcrumbs;
+            if isLoop {
+                fail "loop";
+            } {};
+        } {};
+        move;
+    };
+    end;
+
+/**
+There should only be one place in the
+code where an exception is thrown: that is,
+if a treaded breadcrumb is encountered.
+*/
+def checkIsEnclosedInner =
+    try {
+        goForwardToPatrol false;
+        // Water is the outer boundary
+        hasWater <- ishere "water";
+        if hasWater {
+            return false;
+        } {
+            checkIsEnclosedInner;
+        };
+    } {
+        return true;
+    };
+    end;
+
+def checkIsEnclosed =
+
+    // The "evaporator" drill is used
+    // to clear water tiles.
+    let specialDrill = "evaporator" in
+    create specialDrill;
+    equip specialDrill;
+
+    // NOTE: System robots can walk on water
+    // so we only need this if we want to
+    // demo the algorithm with a player robot.
+//    create "boat";
+//    equip "boat";
+
+    checkIsEnclosedInner;
+    end;
+
+def boolToInt = \b. if (b) {return 1} {return 0}; end;
+
+def countAdjacentBlockages =
+
+    turn left;
+    b1 <- isBlockedOrFenced;
+    c1 <- boolToInt b1;
+
+    turn left;
+    b2 <- isBlockedOrFenced;
+    c2 <- boolToInt b2;
+
+    turn left;
+    b3 <- isBlockedOrFenced;
+    c3 <- boolToInt b3;
+
+    turn left;
+    b4 <- isBlockedOrFenced;
+    c4 <- boolToInt b4;
+
+    return $ c1 + c2 + c3 + c4;
+    end;
+
+// Step forward, observing left and right.
+def observeLeftAndRight =
+    move;
+    turn left;
+    amBlockedLeft <- isBlockedOrFenced;
+    val1 <- boolToInt amBlockedLeft;
+
+    turn back;
+    amBlockedRight <- isBlockedOrFenced;
+    val2 <- boolToInt amBlockedRight;
+
+    turn right;
+    move;
+    return $ val1 + val2;
+    end;
+
+
+/** If the four cardinal directions have at most
+one blockage, then there will exist an orientation
+where both that direction and its opposite direction
+are clear.
+So we can step that direction, check to the left and
+right of us, then step in the opposite direction
+and do the same. This allows us to check the 4
+blocks that touch the corners of the center block.
+*/
+def countDiagonalBlockages =
+    // First, orient to the clear front-to-back path
+    amBlocked <- isBlockedOrFenced;
+    if amBlocked {turn left;} {};
+
+    // Second, step to both sides
+    fwdCount <- observeLeftAndRight;
+    backCount <- observeLeftAndRight;
+    return $ fwdCount + backCount;
+    end;
+
+def isStandingOnBridge =
+    onFence <- ishere "fence";
+    onGate <- ishere "gate";
+    if (onFence || onGate) {
+        adjCount <- countAdjacentBlockages;
+        if (adjCount > 1) {
+            return true;
+        } {
+            diagCount <- countDiagonalBlockages;
+            return $ (adjCount + diagCount) > 1;
+        };
+    } {return false};
+    end;
+
+def getValForSheepIndex = \predicateCmd. \i.
+    try {
+        // This will throw an exception if
+        // the sheep has already drowned.
+        r <- robotnumbered i;
+        didSucceed <- as r {predicateCmd};
+
+        boolToInt didSucceed;
+    } {
+        return 0;
+    }
+    end;
+
+/**
+There are 3 sheep.
+They have indices 1, 2, 3.
+(The base has index 0).
+
+THIS DOES NOT WORK!
+*/
+def countSheepWithRecursive = \predicateCmd. \i.
+
+    if (i > 0) {
+        val <- getValForSheepIndex predicateCmd i;
+        recursiveCount <- countSheepWithRecursive predicateCmd $ i - 1;
+        return $ val + recursiveCount;
+    } {
+        return 0;
+    }
+    end;
+
+
+def countSheepWith = \predicateCmd.
+
+    val1 <- getValForSheepIndex predicateCmd 1;
+    val2 <- getValForSheepIndex predicateCmd 2;
+    val3 <- getValForSheepIndex predicateCmd 3;
+    return $ val1 + val2 + val3;
+
+    end;
+
+
+justFilledGap <- as base {
+    isStandingOnBridge;
+};
+
+if (justFilledGap) {
+    enclosedCount <- countSheepWith checkIsEnclosed;
+    return $ enclosedCount >= 1;
+} {
+    return false;
+}
diff --git a/data/scenarios/Challenges/Ranching/_gated-paddock/fence-construction.sw b/data/scenarios/Challenges/Ranching/_gated-paddock/fence-construction.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/Ranching/_gated-paddock/fence-construction.sw
@@ -0,0 +1,217 @@
+def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end;
+
+def processTree =
+    grab;
+    fenceCount <- count "fence";
+    if (fenceCount < 90) {
+        make "log";
+        make "board";
+        doN 2 $ make "fence";
+    } {};
+    end;
+
+def makeHarvester =
+    doN 3 $ make "log";
+    doN 3 $ make "board";
+    make "box";
+    doN 2 $ make "wooden gear";
+    make "harvester";
+    equip "harvester";
+    end;
+
+def grabTwoRows =
+    doN 7 (processTree; move);
+    processTree;
+    turn right;
+    move;
+    turn right;
+
+    doN 7 (processTree; move);
+    processTree;
+    end;
+
+
+def grabTrees =
+    doN 7 move;
+    turn right;
+
+    grabTwoRows;
+
+    turn left;
+    move;
+    turn left;
+
+    grabTwoRows;
+
+    turn left;
+    move;
+    turn left;
+
+    grabTwoRows;
+
+    end;
+
+
+def harvestIfClover =
+    x <- scan down;
+    case x return (\y.
+        if (y == "clover") {
+            harvest;
+            return ();
+        } {};
+    );
+    end;
+
+
+def buildFence =
+    doN 6 move;
+    turn right;
+    doN 4 (place "fence"; move);
+    turn left;
+    doN 30 (place "fence"; move);
+    turn left;
+    doN 15 (place "fence"; move);
+    turn left;
+    doN 30 (place "fence"; move);
+    turn left;
+    doN 10 (place "fence"; move);
+    make "gate";
+    place "gate";
+    turn right;
+    end;
+
+def gatherClover =
+    doN 8 move;
+    turn left;
+    doN 8 move;
+    turn left;
+
+    doN 19 (harvestIfClover; move;);
+    turn right;
+    move;
+    turn right;
+    doN 19 (harvestIfClover; move;);
+    turn right;
+    end;
+
+def plantCloverColumn = \direction. \extraStep.
+    doN 6 (
+        extraStep;
+        harvest;
+        move;
+    );
+
+    turn direction;
+    move;
+    turn direction;
+    move;
+    end;
+
+def plantCloverField =
+    doN 4 move;
+    
+    plantCloverColumn right $place "clover";
+    plantCloverColumn left $ place "clover";
+    plantCloverColumn right $ place "clover";
+    plantCloverColumn left $ place "clover";
+    plantCloverColumn right $ place "clover";
+    end;
+
+
+def harvestCloverField =
+    doN 5 move;
+    turn right;
+    doN 5 move;
+    turn right;
+    wait 200;
+    
+    plantCloverColumn right $ return ();
+    plantCloverColumn left $ return ();
+    plantCloverColumn right $ return ();
+    plantCloverColumn left $ return ();
+    plantCloverColumn right $ return ();
+    end;
+
+
+def travelRow = \action.
+    doN 28 (action; move);
+    action;
+    end;
+
+def placeCloverRow =
+    travelRow $ place "clover";
+    end;
+
+
+def distributeCloverInPaddock =
+    turn left;
+    doN 4 move;
+    placeCloverRow;
+    turn left;
+    doN 2 move;
+    turn left;
+    placeCloverRow;
+    turn right;
+    doN 2 move;
+    turn right;
+    placeCloverRow;
+    turn left;
+    doN 2 move;
+    turn left;
+    placeCloverRow;
+    end;
+
+
+def pickupWool =
+    x <- scan down;
+    case x return (\y.
+        if (y == "wool") {
+            grab;
+            return ();
+        } {};
+    );
+    end;
+
+def sweep2rows =
+    travelRow pickupWool;
+    turn right;
+    move;
+    turn right;
+    travelRow pickupWool;
+    turn left;
+    move;
+    turn left;
+    end;
+
+
+def sweepAreaForWool =
+    doN 5 sweep2rows;
+    turn left;
+    doN 10 move;
+    turn right;
+
+    woolCount <- count "wool";
+    if (woolCount >= 3) {
+        make "sweater";
+    } {};
+
+    end;
+
+def collectWool =
+    wait 500;
+
+    turn back;
+
+    let forever : cmd unit -> cmd unit = \c. c ; forever c in
+    forever sweepAreaForWool;
+    end;
+
+
+grabTrees;
+makeHarvester;
+buildFence;
+gatherClover;
+plantCloverField;
+doN 3 harvestCloverField;
+distributeCloverInPaddock;
+collectWool;
diff --git a/data/scenarios/Challenges/Ranching/_gated-paddock/meandering-sheep.sw b/data/scenarios/Challenges/Ranching/_gated-paddock/meandering-sheep.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/Ranching/_gated-paddock/meandering-sheep.sw
@@ -0,0 +1,144 @@
+// A "sheep" that wanders around randomly.
+
+/** A "gate" is walkable, so we need to supplement the "blocked" check with this function.
+Since fences are "unwalkable", they do not need to be mentioned in this function.
+*/
+def isFenced =
+    s <- scan forward;
+    return (
+        case s
+            (\_. false)
+            (\x. x == "gate")
+    );
+    end;
+
+def isBlockedOrFenced =
+    b <- blocked;
+    f <- isFenced;
+    return (b || f);
+    end;
+
+def elif = \p.\t.\e. {if p t e} end;
+
+
+def turnToClover = \direction.
+
+  x <- scan direction;
+  case x (\_. return false;) (\y.
+    if (y == "clover") {
+      turn direction;
+      return true;
+    } {
+      return false;
+    };
+  );
+  end;
+
+
+/**
+If there is adjacent clover,
+turn that direction.
+*/
+def turnCloverDirection =
+
+    foundN <- turnToClover north;
+    if (foundN) {return true} {
+      foundE <- turnToClover east;  
+      if (foundE) {return true} {
+        foundS <- turnToClover south;
+        if (foundS) {return true} {
+          turnToClover west;
+        }
+      }
+    }
+    end;
+
+def decideDirection =
+
+  let randdir : cmd dir =
+    d <- random 4;
+    return $ if (d == 0) {
+        north
+      } $ elif (d == 1) {
+        east
+      } $ elif (d == 2) {
+        south
+      } {
+        west
+      }
+    in
+
+  cloverCount <- count "clover";
+  if (cloverCount > 4) {
+    d <- randdir;
+    turn d;
+  } {
+    nearClover <- turnCloverDirection;
+    if (nearClover) {} {
+      d <- randdir;
+      turn d;
+    }
+  }
+  end;
+
+let forever : cmd unit -> cmd unit = \c. c ; forever c in
+let repeat : int -> cmd unit -> cmd unit =
+  \n. \c. if (n == 0) {} {c ; repeat (n-1) c} in
+
+
+forever (
+  n <- random 30;
+  wait (30 + n);
+
+  decideDirection;
+
+  dist <- random 3;
+  repeat dist (
+
+    b <- isBlockedOrFenced;
+    if b {} {
+      move;
+    };
+
+    // Sheep can drown.
+    hasWater <- ishere "water";
+    if hasWater {
+      say "The jolly jumbuck sprang into the billabong. \"You'll never catch me alive!\", said he.";
+      selfdestruct;
+    } {};
+
+    // Eat clover.
+    x <- scan down;
+    case x return (\y.
+        if (y == "clover") {
+            harvest;
+            cloverCount <- count "clover";
+            if (cloverCount < 2) {
+              say "yum!"
+            } {};
+        } {};
+    );
+  );
+  r <- random 30;
+  if (r == 0) { say "baaa" } {};
+
+  hasClover <- has "clover";
+  if (hasClover) {
+    r <- random 8;
+    if (r == 0) { 
+      let item = "wool" in
+      hasWool <- has item;
+      if (hasWool) {
+        // Make sure nothing's in the way before we place
+        // our wool:
+        x <- scan down;
+        case x return (\_.
+          grab;
+          return ();
+        );
+
+        place item;
+      } {};
+    } {};
+  } {};
+)
diff --git a/data/scenarios/Challenges/Ranching/gated-paddock.yaml b/data/scenarios/Challenges/Ranching/gated-paddock.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/Ranching/gated-paddock.yaml
@@ -0,0 +1,543 @@
+version: 1
+name: Pastoral Island
+author: Karl Ostmo
+description: |
+  Start a wool industry from the local fauna.
+creative: false
+objectives:
+  - id: enclose_sheep
+    teaser: Paddock sheep
+    goal:
+      - |
+        You've homesteaded on a small island in the ocean.
+        It's time to gather resources to trade.
+      - |
+        You encounter some feral sheep (@). They slowly wander the island and eat grass.
+        Your mind wanders to textiles...
+      - |
+        First, paddock at least one sheep so they don't drown.
+        Make sure there are no gaps in the fence!
+      - |
+        Note that you can use the "drill" command (by way of the "post puller" tool)
+        to demolish a fence that has been "placed".
+    condition: |-
+      // Algorithm:
+      // ----------
+      // Maintain current direction until a wall is encountered.
+      // Then enter "wall-following mode".
+      // This mode presumes the wall is not a loop.
+      // Wall-following mode exploits recursion to keep track of how many left turns were made
+      // and then unwinds them again by ensuring each is paired with a right turn.
+      // Once the recursion is fully unwound, the robot proceeds along its original direction
+      // (though it may now be laterally displaced).
+      //
+      // (If it was a loop, then an "oriented breadcrumb" would need to be left.
+      // The breadcrumb is oriented in case a single-width passage is backtracked
+      // along the opposite wall.)
+
+      /** A "gate" is walkable, so we need to supplement the "blocked" check with this function.
+      Since fences are "unwalkable", they do not need to be mentioned in this function.
+      */
+      def isFenced =
+          s <- scan forward;
+          return (
+              case s
+                  (\_. false)
+                  (\x. x == "gate")
+          );
+          end;
+
+      def isBlockedOrFenced =
+          b <- blocked;
+          f <- isFenced;
+          return (b || f);
+          end;
+
+      // Returns true if we've already placed two
+      // breadcrumbs on a given tile, false otherwise.
+      def leaveBreadcrumbs =
+
+          let bc1 = "fresh breadcrumb" in
+          let bc2 = "treaded breadcrumb" in
+
+          wasTraversedOnce <- ishere bc1;
+          if wasTraversedOnce {
+              _crumb <- grab;
+              make bc2;
+              place bc2;
+              return false;
+          } {
+              wasTraversedTwice <- ishere bc2;
+              if wasTraversedTwice {
+                  return true;
+              } {
+                  // Make sure nothing's in the way before we place
+                  // our breadcrumb:
+                  x <- scan down;
+                  case x return (\y.
+                      // If we're on a water tile, get rid of
+                      // it with our special "drilling" recipe
+                      if (y == "water") {
+                          drill down;
+                          // Nothing will remain on the ground.
+                          // after making the "steam" via
+                          // the drilling recipe.
+                      } {
+                          grab;
+                          return ();
+                      };
+                  );
+
+                  make bc1;
+                  place bc1;
+                  return false;
+              };
+          };
+          end;
+
+      def goForwardToPatrol = \wasBlocked.
+          b <- isBlockedOrFenced;
+          if b {
+              turn left;
+              goForwardToPatrol true;
+              turn right;
+              goForwardToPatrol false;
+          } {
+              if wasBlocked {
+                  isLoop <- leaveBreadcrumbs;
+                  if isLoop {
+                      fail "loop";
+                  } {};
+              } {};
+              move;
+          };
+          end;
+
+      /**
+      There should only be one place in the
+      code where an exception is thrown: that is,
+      if a treaded breadcrumb is encountered.
+      */
+      def checkIsEnclosedInner =
+          try {
+              goForwardToPatrol false;
+              // Water is the outer boundary
+              hasWater <- ishere "water";
+              if hasWater {
+                  return false;
+              } {
+                  checkIsEnclosedInner;
+              };
+          } {
+              return true;
+          };
+          end;
+
+      def checkIsEnclosed =
+
+          // The "evaporator" drill is used
+          // to clear water tiles.
+          let specialDrill = "evaporator" in
+          create specialDrill;
+          equip specialDrill;
+
+          // NOTE: System robots can walk on water
+          // so we only need this if we want to
+          // demo the algorithm with a player robot.
+      //    create "boat";
+      //    equip "boat";
+
+          checkIsEnclosedInner;
+          end;
+
+      def boolToInt = \b. if (b) {return 1} {return 0}; end;
+
+      def countAdjacentBlockages =
+
+          turn left;
+          b1 <- isBlockedOrFenced;
+          c1 <- boolToInt b1;
+
+          turn left;
+          b2 <- isBlockedOrFenced;
+          c2 <- boolToInt b2;
+
+          turn left;
+          b3 <- isBlockedOrFenced;
+          c3 <- boolToInt b3;
+
+          turn left;
+          b4 <- isBlockedOrFenced;
+          c4 <- boolToInt b4;
+
+          return $ c1 + c2 + c3 + c4;
+          end;
+
+      // Step forward, observing left and right.
+      def observeLeftAndRight =
+          move;
+          turn left;
+          amBlockedLeft <- isBlockedOrFenced;
+          val1 <- boolToInt amBlockedLeft;
+
+          turn back;
+          amBlockedRight <- isBlockedOrFenced;
+          val2 <- boolToInt amBlockedRight;
+
+          turn right;
+          move;
+          return $ val1 + val2;
+          end;
+
+
+      /** If the four cardinal directions have at most
+      one blockage, then there will exist an orientation
+      where both that direction and its opposite direction
+      are clear.
+      So we can step that direction, check to the left and
+      right of us, then step in the opposite direction
+      and do the same. This allows us to check the 4
+      blocks that touch the corners of the center block.
+      */
+      def countDiagonalBlockages =
+          // First, orient to the clear front-to-back path
+          amBlocked <- isBlockedOrFenced;
+          if amBlocked {turn left;} {};
+
+          // Second, step to both sides
+          fwdCount <- observeLeftAndRight;
+          backCount <- observeLeftAndRight;
+          return $ fwdCount + backCount;
+          end;
+
+      def isStandingOnBridge =
+          onFence <- ishere "fence";
+          onGate <- ishere "gate";
+          if (onFence || onGate) {
+              adjCount <- countAdjacentBlockages;
+              if (adjCount > 1) {
+                  return true;
+              } {
+                  diagCount <- countDiagonalBlockages;
+                  return $ (adjCount + diagCount) > 1;
+              };
+          } {return false};
+          end;
+
+      def getValForSheepIndex = \predicateCmd. \i.
+          try {
+              // This will throw an exception if
+              // the sheep has already drowned.
+              r <- robotnumbered i;
+              didSucceed <- as r {predicateCmd};
+
+              boolToInt didSucceed;
+          } {
+              return 0;
+          }
+          end;
+
+      /**
+      There are 3 sheep.
+      They have indices 1, 2, 3.
+      (The base has index 0).
+
+      THIS DOES NOT WORK!
+      */
+      def countSheepWithRecursive = \predicateCmd. \i.
+
+          if (i > 0) {
+              val <- getValForSheepIndex predicateCmd i;
+              recursiveCount <- countSheepWithRecursive predicateCmd $ i - 1;
+              return $ val + recursiveCount;
+          } {
+              return 0;
+          }
+          end;
+
+
+      def countSheepWith = \predicateCmd.
+
+          val1 <- getValForSheepIndex predicateCmd 1;
+          val2 <- getValForSheepIndex predicateCmd 2;
+          val3 <- getValForSheepIndex predicateCmd 3;
+          return $ val1 + val2 + val3;
+
+          end;
+
+
+      justFilledGap <- as base {
+          isStandingOnBridge;
+      };
+
+      if (justFilledGap) {
+          enclosedCount <- countSheepWith checkIsEnclosed;
+          return $ enclosedCount >= 1;
+      } {
+          return false;
+      }
+  - id: feed_sheep
+    teaser: Feed sheep
+    goal:
+      - |
+        Safe! Your sheep are now hungry.
+        Offer them something tasty and you may be rewarded.
+      - |
+        The sheep will move toward something edible on
+        an adjacent tile and will eat it if they walk over it.
+      - |
+        You may want to add a gate to the fence
+        to give yourself easier access.
+    condition: |-
+      def getTruthForSheepIndex = \predicateCmd. \i.
+          try {
+              // This will throw an exception if
+              // the sheep has already drowned.
+              r <- robotnumbered i;
+              as r {predicateCmd};
+          } {
+              return false;
+          };
+          end;
+
+      def anySheep = \predicateCmd. \i.
+
+          if (i > 0) {
+              didSucceed <- getTruthForSheepIndex predicateCmd i;
+              if didSucceed {
+                  return true;
+              } {
+                  anySheep predicateCmd $ i - 1;
+              };
+          } {
+              return false;
+          };
+          end;
+
+      anySheep (has "clover") 3;
+    prerequisite: enclose_sheep
+  - goal:
+      - |
+        Yum! Contented, well-fed sheep may drop wool.
+      - |
+        Winter is coming! Collect three wool bundles to make a sweater.
+      - |
+        Each sheep drops a finite amount over
+        their lifetime.
+    condition: |-
+      as base {
+        has "sweater";
+      };
+    prerequisite: feed_sheep
+robots:
+  - name: base
+    dir: [0, 1]
+    devices:
+      - treads
+      - scanner
+      - dictionary
+      - branch predictor
+      - strange loop
+      - clock
+      - ADT calculator
+      - comparator
+      - workbench
+      - grabber
+      - welder
+      - lambda
+      - logger
+      - hearing aid
+      - counter
+      - mirror
+      - post puller
+    inventory:
+      - [0, fence]
+      - [0, gate]
+      - [10, hinge]
+  - name: sheep
+    description:
+      - meandering livestock
+    display:
+      invisible: false
+      char: '@'
+    system: true
+    dir: [0, 1]
+    inventory:
+      - [4, wool]
+    program: |
+      run "scenarios/Challenges/Ranching/_gated-paddock/meandering-sheep.sw";
+entities:
+  - name: fence
+    display:
+      char: '#'
+    description:
+      - Keeps sheep in. And some other things out.
+    properties: [known, portable, unwalkable]
+  - name: post puller
+    display:
+      char: 'P'
+      attr: rock
+    capabilities: [drill]
+    description:
+      - Good for dismantling fences.
+    properties: [known, portable]
+  - name: scrap wood
+    display:
+      char: '\'
+    description:
+      - Scrap wood. Can be reconditioned into boards.
+    properties: [known, portable]
+  - name: sweater
+    display:
+      attr: gold
+      char: 'S'
+    description:
+      - A warm wool sweater. Just in time for winter!
+    properties: [known, portable]
+  - name: clover
+    display:
+      attr: flower
+      char: '%'
+    description:
+      - A tasty stack for fluffy ruminants.
+    properties: [portable, growable]
+    growth: [80, 100]
+  - name: gate
+    display:
+      char: '/'
+      attr: rock
+    description:
+      - A gate permits the player to pass through, but sheep cannot.
+    properties: [known]
+  - name: hinge
+    display:
+      char: 'U'
+      attr: rock
+    description:
+      - Facilitates swinging action.
+    properties: [known, portable]
+  - name: cabin
+    display:
+      char: Π
+      attr: rock
+    description:
+      - Home sweet home.
+    properties: [known, unwalkable]
+  - name: pier
+    display:
+      char: 且
+      attr: rock
+    description:
+      - Docking area for ships
+    properties: [known]
+  - name: fresh breadcrumb
+    display:
+      char: '.'
+    description:
+      - A marker that can be put down and found again. Used only by judge robot.
+    properties: [portable]
+  - name: treaded breadcrumb
+    display:
+      char: 'x'
+    description:
+      - A marker that can be put down and found again (for a second time). Used only by judge robot.
+    properties: [portable]
+  - name: evaporator
+    display:
+      char: 'E'
+    description:
+      - A tool that allows clearing a water tile. Used only by judge robot.
+    properties: [portable]
+    capabilities: [drill]
+  - name: wool
+    display:
+      char: 'ω'
+      attr: gold
+    description:
+      - A bundle of raw animal fiber.
+    properties: [portable]
+  - name: steam
+    display:
+      char: 'Z'
+    description:
+      - What's left after evaporating water. Used only by judge robot.
+    properties: [portable]
+recipes:
+  - in:
+      - [2, board]
+    out:
+      - [1, fence]
+  - in:
+      - [3, wool]
+    out:
+      - [1, sweater]
+  - in:
+      - [1, scrap wood]
+    out:
+      - [1, board]
+  - in:
+      - [0, fresh breadcrumb]
+    out:
+      - [1, fresh breadcrumb]
+  - in:
+      - [1, fresh breadcrumb]
+    out:
+      - [1, treaded breadcrumb]
+  - in:
+      - [1, fence]
+    out:
+      - [1, scrap wood]
+    required:
+      - [1, post puller]
+  - in:
+      - [1, water]
+    out:
+      - [1, steam]
+    required:
+      - [1, evaporator]
+  - in:
+      - [1, hinge]
+      - [1, fence]
+    out:
+      - [1, gate]
+known: [mountain, tree, water]
+seed: 0
+solution: |
+  run "scenarios/Challenges/Ranching/_gated-paddock/fence-construction.sw"
+world:
+  default: [dirt, water]
+  palette:
+    'B': [grass, null, base]
+    '.': [grass]
+    't': [dirt, tree]
+    'x': [stone, mountain]
+    'c': [stone, cabin]
+    's': [grass, null, sheep]
+    '%': [grass, clover, null]
+    'H': [stone, pier, null]
+    '~': [dirt, water]
+  upperleft: [-34, 11]
+  map: |-
+    ~~~~.......~~~~~~~~~~~~~.......................~~~~~~
+    ~~.............~~~~.......%.%%%%%%..%%%%%.%.......~~~
+    ~........................%%%%%%%%%%%%%..%%%%.......~~
+    ~...................................................~
+    .............................x.xx........tttttttt...~
+    .........................................tttttttt...~
+    ..........................xxxxx..........tttttttt...~
+    .........................................tttttttt...~
+    ...........s.............................tttttttt....
+    ....x....................................tttttttt....
+    ...xx................................................
+    ..xx.....................................B......c....
+    ...x.................................................
+    ..............................s......................
+    .....................................................
+    ....................................................~
+    ~..............s...................................~~
+    ~~.......xxx...........x.........................~~~~
+    ~~~............xx............x............~~~~H~~~~~~
+    ~~~.....................................~~~~~~H~~~~~~
+    ~~.....................................~~~~~~~H~~~~~~
+    ~~..............................~~~~~~~~~~~~~~H~~~~~~
+    ~~~~...................~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/data/scenarios/Challenges/_bucket-brigade/brigade.sw b/data/scenarios/Challenges/_bucket-brigade/brigade.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_bucket-brigade/brigade.sw
@@ -0,0 +1,94 @@
+def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end;
+
+def forever = \c. c; forever c; end;
+
+def pollUntilHas = \item.
+    itemInInventory <- has item;
+    if itemInInventory {} {
+        pollUntilHas item;
+    };
+    end;
+
+def pollUntilCount = \targetCount. \item.
+    itemCount <- count item;
+    if (itemCount < targetCount) {
+        pollUntilCount targetCount item;
+    } {};
+    end;
+
+def waitToGiveUntilHas = \item. \recipient.
+    pollUntilHas item;
+    give recipient item;
+    end;
+
+/** The "depth" is the number of spaces between
+the root robot and the target item.
+It is one minus the distance to the target item.
+*/
+def recurseUntilDepth = \depth.
+    let item = "coal lump" in
+    child <- build {
+
+        // NOTE: Treads are auto-equipped via the implicit "require"
+        move;
+        unequip "treads";
+
+        pollUntilCount depth "repro kit";
+
+        // Use the recipe from "repro kit"
+        make "3D printer";
+
+        equip "solar panel";
+        equip "grabber";
+        equip "3D printer";
+        equip "dictionary";
+        equip "branch predictor";
+        equip "comparator";
+        equip "calculator";
+        equip "lambda";
+        equip "strange loop";
+        equip "string";
+        equip "clock";
+        equip "counter";
+        equip "mirror";
+        equip "scanner";
+        equip "logger";
+        equip "net";
+
+        if (depth > 1) {
+            recurseUntilDepth $ depth - 1;
+            forever $ waitToGiveUntilHas item parent;
+        } {
+            make "bucketwheel excavator";
+            forever (
+                drill forward;
+                give parent item;
+            );
+        };
+    };
+
+    let kitGiftCount = depth in
+    // say $ "Going to give child " ++ (format kitGiftCount) ++ " repro kits.";
+    doN kitGiftCount $ give child "repro kit";
+
+    end;
+
+def makeBriquette =
+    let briquette = "coal briquette" in
+    pollUntilCount 100 "coal lump";
+    make briquette;
+    briquetteAlreadyPlaced <- ishere briquette;
+    if briquetteAlreadyPlaced {} {
+        place briquette;
+    };
+    end;
+
+def go =
+    // Use the recipe from "repro kit" to unpack the right
+    // set of items to be taken from the inventory with the implicit "require":
+    make "3D printer";
+    recurseUntilDepth 8;
+    forever makeBriquette;
+    end;
+
+go;
diff --git a/data/scenarios/Challenges/_bucket-brigade/hauler.sw b/data/scenarios/Challenges/_bucket-brigade/hauler.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_bucket-brigade/hauler.sw
@@ -0,0 +1,44 @@
+def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end;
+
+/** The function argument returns a boolean.
+Keep running the function until either the
+loop counter runs out or the function returns true.
+*/
+def doNTimesOr = \n. \f.
+    if (n > 0) {
+        shouldQuit <- f;
+        if shouldQuit {} {
+            doN (n - 1) f;
+        }
+    } {};
+    end;
+
+def forever = \c. c; forever c; end;
+
+def traverseRoad =
+    doN 11 (
+        move;
+        wait 7;
+    );
+    end;
+
+def patrol =
+    let briquette = "coal briquette" in
+    traverseRoad;
+    doNTimesOr 40 (
+        briquetteIsHere <- ishere briquette;
+        if briquetteIsHere {grab; return ()} {};
+        return briquetteIsHere;
+    );
+    turn back;
+    traverseRoad;
+
+    hasBriquette <- has briquette;
+    if hasBriquette {
+        say "Delivered!";
+        make "energy";
+    } {};
+    turn back;
+    end;
+
+forever patrol;
diff --git a/data/scenarios/Challenges/_bucket-brigade/powerplant.sw b/data/scenarios/Challenges/_bucket-brigade/powerplant.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_bucket-brigade/powerplant.sw
@@ -0,0 +1,20 @@
+def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end;
+
+def flashLights =
+    doN 8 (
+        move;
+        wait 8;
+        turn back;
+    );
+    end;
+
+def checkForEnergy = \lastCount.
+    hauler <- robotnamed "hauler";
+    energyCount <- as hauler {
+        count "energy";
+    };
+    if (energyCount > lastCount) {flashLights} {};
+    checkForEnergy energyCount;
+    end;
+
+checkForEnergy 0;
diff --git a/data/scenarios/Challenges/_friend/cat.sw b/data/scenarios/Challenges/_friend/cat.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_friend/cat.sw
@@ -0,0 +1,75 @@
+def forever : cmd unit -> cmd unit = \c. c ; forever c end
+
+def repeat : int -> cmd unit -> cmd unit =
+  \n. \c. if (n == 0) {} {c ; repeat (n-1) c}
+end
+
+def elif = \t. \then. \else. {if t then else} end
+def else = \t. t end
+
+def abs = \n. if (n < 0) {-n} {n} end
+
+def randdir : cmd dir =
+  d <- random 4;
+  return (
+    if (d == 0) {north}
+    $ elif (d == 1) {east}
+    $ elif (d == 2) {south}
+    $ else {west}
+  )
+end
+
+def chooseWait : cmd int =
+  t <- random (16*2);
+  return (16 + t)
+end
+
+def wander =
+  d <- randdir;
+  turn d;
+  dist <- random 2;
+  try {repeat dist move} {};
+  r <- random 5;
+  if (r == 0) { say "meow" } {}
+end
+
+def disappointed = \cat. say "meow??"; cat end
+
+def follow : cmd unit -> actor -> cmd unit = \cat. \r.
+  rLoc <- as r {whereami};
+  myLoc <- whereami;
+  let dx = fst rLoc - fst myLoc in
+  let dy = snd rLoc - snd myLoc in
+  if (abs dx > abs dy)
+  { if (dx < 0) {turn west} {turn east} }
+  { if (dy < 0) {turn south} {turn north} };
+  if (abs dx != 0 || abs dy != 0) {try { move } { disappointed cat }} {};
+  wait 4;
+  follow cat r
+end
+
+def love = \cat.
+  say "purr";
+  fishGiver <- meet;
+  case fishGiver
+    (\_. disappointed cat)
+    (\r. follow cat r)
+end
+
+def cat = \start. \fishCount. \waitTime.
+  if (waitTime == 0) { wander; start } { wait 1 };
+  n <- count "fish";
+  if (n > fishCount)
+  { say "yum!";
+    if (n >= 3) { love start } { cat start n (waitTime - 1) }
+  }
+  { cat start fishCount (waitTime - 1) }
+end
+
+def startCat =
+  n <- count "fish";
+  w <- chooseWait;
+  cat startCat n w
+end;
+
+startCat
diff --git a/data/scenarios/Challenges/_friend/friend-solution.sw b/data/scenarios/Challenges/_friend/friend-solution.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_friend/friend-solution.sw
@@ -0,0 +1,18 @@
+def m2 = move; move end
+def m5 = m2; m2; move end
+def m10 = m5; m5 end
+def m20 = m10; m10 end
+
+def give_fish = \n.
+  if (n == 0) {}
+  { mcat <- meet;
+    case mcat (\_. give_fish n) (\cat. give cat "fish"; give_fish (n-1))
+  }
+end;
+
+build {
+  require 3 "fish";
+  m2; turn left; m20;
+  give_fish 3;
+  turn back; m20; turn right; m2
+}
diff --git a/data/scenarios/Challenges/_ice-cream/customer.sw b/data/scenarios/Challenges/_ice-cream/customer.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_ice-cream/customer.sw
@@ -0,0 +1,60 @@
+def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end;
+
+def approachShop =
+    doN 5 (
+        move;
+        wait 8;
+    );
+    end;
+
+def placeOrder =
+    scoopCountExtra <- random 4000;
+    let scoopCount = 1000 + scoopCountExtra in
+    say $ format scoopCount ++ " scoops of vanilla ice cream, no more, no less! And top it with a cherry, please.";
+    return scoopCount;
+    end;
+
+def waitForItem = \item.
+    hasItem <- has item;
+    if hasItem {
+        return ();
+    } {
+        wait 1;
+        waitForItem item;
+    }
+    end;
+
+/**
+If we receive the cherry
+when we also have the correct number of
+scoops, then we produce this item
+as a sempahore for the goal evaluation.
+
+Otherwise, we express our disappointment.
+*/
+def createCompletedSemaphore = \targetCount.
+    scoopcount <- count "scoop";
+    if (scoopcount < targetCount) {
+        say "That's not enough ice cream! Terrible service.";
+    } {
+        if (scoopcount > targetCount) {
+            say "That's too much ice cream!";
+        } {
+            say "Just right! Here's your money.";
+            give base "bitcoin";
+        };
+    };
+    turn back;
+    approachShop;
+    selfdestruct;
+    end;
+
+def runScript =
+    approachShop;
+    waitForItem "cone";
+    targetScoopCount <- placeOrder;
+    waitForItem "cherry";
+    createCompletedSemaphore targetScoopCount;
+    end;
+
+runScript;
diff --git a/data/scenarios/Challenges/_ice-cream/solution.sw b/data/scenarios/Challenges/_ice-cream/solution.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_ice-cream/solution.sw
@@ -0,0 +1,68 @@
+def serveCustomer = \cone. \customer.
+    give customer cone;
+    end;
+
+def getCounter =
+    move;
+    c <- grab;
+    equip c;
+    end;
+
+def getBriefcase =
+    turn left;
+    move;
+    move;
+    b <- grab;
+    equip b;
+    turn back;
+    move;
+    move;
+    end;
+
+def getIngredients =
+    move;
+    move;
+    cone <- grab;
+    move;
+    cherry <- grab;
+    turn back;
+    move;
+    move;
+    move;
+    turn right;
+    return (cone, cherry);
+    end;
+
+def meetCustomer =
+    maybeCustomer <- meet;
+    case maybeCustomer (\_. meetCustomer) return;
+    end;
+
+def serveScoop = \customer.
+    let s = "scoop" in
+    make s;
+    give customer s;
+    end;
+
+def scoopUntil = \customer. \targetRemainingIngredientCount.
+    currentCount <- count "milk";
+    if (currentCount > targetRemainingIngredientCount) {
+        serveScoop customer;
+        scoopUntil customer targetRemainingIngredientCount;
+    } {};
+    end;
+
+def runSolution = \targetRemainingIngredientCount.
+    getBriefcase;
+    coneAndCherry <- getIngredients;
+    getCounter;
+    customer <- meetCustomer;
+
+    let cone = fst coneAndCherry in
+    let cherry = snd coneAndCherry in
+    serveCustomer cone customer;
+    scoopUntil customer targetRemainingIngredientCount;
+    give customer cherry;
+    end;
+
+runSolution 2712;
diff --git a/data/scenarios/Challenges/_wolf-goat-cabbage/cross.sw b/data/scenarios/Challenges/_wolf-goat-cabbage/cross.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_wolf-goat-cabbage/cross.sw
@@ -0,0 +1,40 @@
+def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end;
+
+def crossRiver =
+    doN 18 move;
+    end;
+
+move;
+move;
+goat <- grab;
+turn east;
+crossRiver;
+place goat;
+turn back;
+crossRiver;
+turn right;
+move;
+wolf <- grab;
+turn right;
+crossRiver;
+place wolf;
+turn right;
+move;
+goat2 <- grab;
+turn right;
+crossRiver;
+place goat2;
+turn left;
+move;
+cabbage <- grab;
+turn left;
+crossRiver;
+place cabbage;
+turn back;
+crossRiver;
+turn right;
+move;
+goat3 <- grab;
+turn right;
+crossRiver;
+place goat3;
diff --git a/data/scenarios/Challenges/_wolf-goat-cabbage/multi-item-possession.sw b/data/scenarios/Challenges/_wolf-goat-cabbage/multi-item-possession.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_wolf-goat-cabbage/multi-item-possession.sw
@@ -0,0 +1,4 @@
+has_wolf <- has "wolf";
+has_goat <- has "goat";
+has_cabbage <- has "cabbage";
+return $ (has_wolf && has_goat) || (has_goat && has_cabbage) || (has_wolf && has_cabbage);
diff --git a/data/scenarios/Challenges/_wolf-goat-cabbage/together-on-east-bank.sw b/data/scenarios/Challenges/_wolf-goat-cabbage/together-on-east-bank.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_wolf-goat-cabbage/together-on-east-bank.sw
@@ -0,0 +1,28 @@
+def get_x_coord = \r.
+    as r {
+        pos <- whereami;
+        return $ fst pos;
+    };
+    end;
+
+def all_on_bank = \baseX. \robotName.
+    r <- robotnamed robotName;
+    thisX <- get_x_coord r;
+
+    as r {
+        try {grab; return ()} {};
+        move;
+        try {grab; return ()} {};
+        move;
+        try {grab; return ()} {};
+
+        has_wolf <- has "wolf";
+        has_goat <- has "goat";
+        has_cabbage <- has "cabbage";
+        return $ baseX == thisX && has_wolf && has_goat && has_cabbage;
+    };
+    end;
+
+baseX <- get_x_coord base;
+
+all_on_bank baseX "east_detector";
diff --git a/data/scenarios/Challenges/_wolf-goat-cabbage/unattended-together.sw b/data/scenarios/Challenges/_wolf-goat-cabbage/unattended-together.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_wolf-goat-cabbage/unattended-together.sw
@@ -0,0 +1,35 @@
+def get_x_coord = \r.
+    as r {
+        pos <- whereami;
+        return $ fst pos;
+    };
+    end;
+
+def is_unattended_together = \baseX. \robotName.
+    r <- robotnamed robotName;
+    thisX <- get_x_coord r;
+
+    if (baseX == thisX) {
+        return false;
+    } {
+        as r {
+            try {grab; return ()} {};
+            move;
+            try {grab; return ()} {};
+            move;
+            try {grab; return ()} {};
+
+            has_wolf <- has "wolf";
+            has_goat <- has "goat";
+            has_cabbage <- has "cabbage";
+            return $ (has_wolf && has_goat) || (has_goat && has_cabbage);
+        };        
+    }
+    end;
+
+baseX <- get_x_coord base;
+
+west_bad <- is_unattended_together baseX "west_detector";
+east_bad <- is_unattended_together baseX "east_detector";
+
+return $ west_bad || east_bad;
diff --git a/data/scenarios/Challenges/_word-search/create-puzzle.sw b/data/scenarios/Challenges/_word-search/create-puzzle.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_word-search/create-puzzle.sw
@@ -0,0 +1,247 @@
+def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end;
+
+def intersperse = \n. \f2. \f1. if (n > 0) {
+        f1;
+        if (n > 1) {
+            f2;
+        } {};
+        intersperse (n - 1) f2 f1;
+    } {};
+    end;
+
+def whichOrdinal = \str.
+    if (str == "capital C") {
+        return 0;
+    } {
+        if (str == "capital O") {
+            return 1;
+        } {
+            if (str == "capital W") {
+                return 2;
+            } {
+                return (-1);
+            }
+        }
+    }
+    end;
+
+/**
+Returns -1 if not a recognized letter.
+*/
+def getAdjacentOrdinal = \d.
+    maybeEntity <- scan d;
+    str <- case maybeEntity (\_. return "") (\s. return s);
+    whichOrdinal str;
+    end;
+
+/**
+Call a function repeatedly with the numeric argument,
+in descending order.
+*/
+def iterN = \n. \f.
+    if (n > 0) {
+        let newNum = n - 1 in
+        f newNum;
+        iterN newNum f;
+    } {};
+    end;
+
+def chooseLetter = \i.
+    if (i == 0) {
+        return "capital C";
+    } {
+        if (i == 1) {
+            return "capital O";
+        } {
+            return "capital W";
+        }
+    };
+    end;
+
+/**
+We only need to probe further if
+the letter above us is an O.
+Then we need to check whether the letter
+above that is a C or a W.
+*/
+def getExcludedVerticalLetter =
+    northOrdinal <- getAdjacentOrdinal north;
+    if (northOrdinal == 1) {
+        currentLoc <- whereami;
+        teleport self (fst currentLoc, snd currentLoc + 1);
+        doubleNorthOrdinal <- getAdjacentOrdinal north;
+        teleport self currentLoc;
+
+        if (doubleNorthOrdinal == 2) {
+            return 0;
+        } {
+            if (doubleNorthOrdinal == 0) {
+                return 2;
+            } {
+                return (-1);
+            }
+        }
+    } {
+        return (-1);
+    }
+    end;
+
+/**
+To ensure there are limited numbers of solutions
+(preferably exactly one),
+make sure we're not completing a word
+horizontally (foward or backward)
+or vertically (upward or downward), except if we
+are in the designated location.
+*/
+def reRoll = \excludedVertical. \expectedFwdOrdinal. \expectedBkwdOrdinal.
+
+    // NOTE: excludeTwo and excludeZero
+    // are mutually exclusive!
+    let excludeTwo = expectedFwdOrdinal == 2 in
+    let excludeZero = expectedBkwdOrdinal == 0 in
+
+    // NOTE: Excluded letters can only be 0 or 2.
+    // 1 is always a valid option.
+
+    if excludeZero {
+        if (excludedVertical == 2) {
+            return 1;
+        } {
+            // Zero is the only excluded value,
+            // so just offset a choice between 0 and 1 upward by 1, 
+            // to make it a choice between 1 and 2.
+            val <- random 2;
+            return $ val + 1;
+        };
+    } {
+        if excludeTwo {
+            if (excludedVertical == 0) {
+                return 1;
+            } {
+                // Two is the only excluded value,
+                // so make it a choice between 0 and 1.
+                random 2;
+            };
+        } {
+            if (excludedVertical == 0) {
+                // Zero is the only excluded value,
+                // so just offset a choice between 0 and 1 upward by 1, 
+                // to make it a choice between 1 and 2.
+                val <- random 2;
+                return $ val + 1;
+            } {
+                if (excludedVertical == 2) {
+                    // Two is the only excluded value,
+                    // so make it a choice between 0 and 1.
+                    random 2;
+                } {
+                    // No values are excluded, so select random from
+                    // the full range.
+                    random 3;
+                }
+            };
+        };
+    };
+    end;
+
+def singleTile = \expectedFwdOrdinal. \expectedBkwdOrdinal.
+    excludedVertical <- getExcludedVerticalLetter;
+    letterIndex <- reRoll excludedVertical expectedFwdOrdinal expectedBkwdOrdinal;
+    chosenLetter <- chooseLetter letterIndex;
+    place chosenLetter;
+    return letterIndex;
+    end;
+
+def crossBack = \n.
+    currentLoc <- whereami;
+    teleport self (0, snd currentLoc - 1);
+    end;
+
+/**
+Recursive. Tracks the completion of the word in both the forward
+and backward directions.
+*/
+def layTilesRow = \expectedFwdOrdinal. \expectedBkwdOrdinal. \n.
+    placedIndex <- singleTile expectedFwdOrdinal expectedBkwdOrdinal;
+
+    if (n > 1) {
+        move;
+
+        newFwdOrdinal <- if (placedIndex == expectedFwdOrdinal || placedIndex == 0) {
+            return $ placedIndex + 1;
+        } {
+            return 0;
+        };
+
+        newBkwdOrdinal <- if (placedIndex == expectedBkwdOrdinal || placedIndex == 2) {
+            return $ placedIndex - 1;
+        } {
+            return 2;
+        };
+
+        layTilesRow newFwdOrdinal newBkwdOrdinal $ n - 1;
+    } {};
+    end;
+
+def giveLetterNumbered = \n.
+    letter <- chooseLetter n;
+    give base letter;
+    end;
+
+def removeBoulder =
+    baseLoc <- as base {whereami};
+    teleport self (fst baseLoc - 1, snd baseLoc);
+
+    // Remove the boulder blocking the player's path
+    grab;
+
+    // Make sure the base "knows" about the letters
+    // to get rid of the question marks ("?").
+    iterN 3 giveLetterNumbered;
+
+    selfdestruct;
+    end;
+
+def createImpossiblePuzzle = \width. \height.
+    intersperse height (crossBack width) (layTilesRow 0 2 width);
+    end;
+
+/**
+This word center is padded by
+one cell from the vertical and horizontal
+edges of the playfield.
+This means that the word will not exist
+horizontally at either the top or bottom edge,
+nor will it exist vertically at the left or
+right edge of the playfield.
+*/
+def overwriteWithWord = \width. \height.
+
+    randX <- random $ width - 2;
+    randY <- random $ height - 2;
+    let insertionX = 1 + randX in
+    let insertionY = 1 + randY in
+    teleport self (insertionX, -insertionY);
+
+    // Rotate to random orientation
+    turnCount <- random 3;
+    doN turnCount $ turn left;
+
+    move;
+    turn back;
+
+    // Place all three letters of the word
+    iterN 3 $ \x.
+        chosenLetter <- chooseLetter x;
+        swap chosenLetter;
+        move;
+    end;
+
+def createPuzzle = \width. \height.
+    createImpossiblePuzzle width height;
+    overwriteWithWord width height;
+    removeBoulder;
+    end;
+
+createPuzzle 25 15;
diff --git a/data/scenarios/Challenges/_word-search/solution.sw b/data/scenarios/Challenges/_word-search/solution.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_word-search/solution.sw
@@ -0,0 +1,127 @@
+def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end;
+
+def intersperse = \n. \f2. \f1. if (n > 0) {
+        f1;
+        if (n > 1) {
+            f2;
+        } {};
+        intersperse (n - 1) f2 f1;
+    } {};
+    end;
+
+def waitUntilUnblocked =
+    x <- blocked;
+    if x {
+        wait 1;
+        waitUntilUnblocked;
+    } {};
+    end;
+
+def whichOrdinal =
+    isC <- ishere "capital C";
+    if (isC) {
+        return 0;
+    } {
+        isO <- ishere "capital O";
+        if (isO) {
+            return 1;
+        } {
+            isW <- ishere "capital W";
+            if (isW) {
+                return 2;
+            } {
+                return (-1);
+            }
+        }
+    }
+    end;
+
+// Go to upper-left corner
+def goToCorner =
+    myLoc <- whereami;
+    doN (fst myLoc) move;
+    turn right;
+    doN (-(snd myLoc)) move;
+    turn right;
+    end;
+
+def highlightLetter =
+    drill down;
+    end;
+
+def traverseRow = \expectedOrdinal. \colCount.
+
+    theFoundOrdinal <- whichOrdinal;
+
+    // Logic: the first letter of the target word is *always*
+    // considered a "match".
+    let shouldAdvance = theFoundOrdinal == expectedOrdinal || theFoundOrdinal == 0 in
+    newExpectedOrdinal <- if shouldAdvance {
+        return $ theFoundOrdinal + 1;
+    } {
+        // Reset the progress
+        return 0;
+    };
+
+    if (newExpectedOrdinal == 3) {
+        turn back;
+
+        intersperse 3 move highlightLetter;
+        return true;
+    } {
+        if (colCount > 1) {
+            move;
+            traverseRow newExpectedOrdinal (colCount - 1);
+        } {
+          return false;
+        };
+    };
+    end;
+
+def advanceRow =
+    turn left;
+    move;
+    turn left;
+    end;
+
+/**
+Travels forward and then backward
+across a row, to check for solutions
+in either direction.
+*/
+def traverseCols = \width. \height. 
+    didWin <- traverseRow 0 width;
+    if didWin {
+        return true;
+    } {
+        turn back;
+        didWinBackward <- traverseRow 0 width;
+        if didWinBackward {
+            return true;
+        } {
+            if (height > 1) {
+                advanceRow;
+                traverseCols width $ height - 1;
+            } {
+                return false;
+            };
+        }
+    } 
+    end;
+
+def solve = \boardWidth. \boardHeight.
+    waitUntilUnblocked;
+    goToCorner;
+
+    wonHorizontally <- traverseCols boardWidth boardHeight;
+    if wonHorizontally {
+        return true;
+    } {
+        // If we did not find a horizontal solution,
+        // look for vertical solutions.
+        turn right;
+        traverseCols boardHeight boardWidth;
+    }
+    end;
+
+solve 25 15;
diff --git a/data/scenarios/Challenges/_word-search/verify-solution.sw b/data/scenarios/Challenges/_word-search/verify-solution.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/_word-search/verify-solution.sw
@@ -0,0 +1,183 @@
+/**
+Algorithm:
+We only need to check the base's
+current position: if we find three contiguous highlights,
+then we know that the player has just completed their
+third highlight.
+*/
+
+def whichOrdinal =
+    isC <- ishere "lowercase c";
+    if (isC) {
+        return 0;
+    } {
+        isO <- ishere "lowercase o";
+        if (isO) {
+            return 1;
+        } {
+            isW <- ishere "lowercase w";
+            if (isW) {
+                return 2;
+            } {
+                return (-1);
+            }
+        }
+    }
+    end;
+
+
+def whichHighlightedOrdinal = \str.
+    if (str == "lowercase c") {
+        return 0;
+    } {
+        if (str == "lowercase o") {
+            return 1;
+        } {
+            if (str == "lowercase w") {
+                return 2;
+            } {
+                return (-1);
+            }
+        }
+    }
+    end;
+
+def countConsecutive = \expectedOrdinal. \n.
+
+    thisOrdinal <- whichOrdinal;
+    nextOrdinal <- if (thisOrdinal == expectedOrdinal) {
+        return $ expectedOrdinal + 1;
+    } {
+        return 0;
+    };
+
+    if (nextOrdinal == 3) {
+        return true;
+    } {
+        if (n > 1) {
+            move;
+            countConsecutive nextOrdinal $ n - 1;
+        } {
+            return false;
+        };
+    };
+    end;
+
+def checkBackAndForth =
+
+    foundBackward <- countConsecutive 0 3;
+    if (foundBackward) {
+        return true;
+    } {
+        turn back;
+        countConsecutive 0 3;
+    }
+    end;
+
+def checkDirections = \n.
+    if (n > 0) {
+        wasFound <- checkBackAndForth;
+        if wasFound {
+            return true;
+        } {
+            turn left;
+            checkDirections $ n - 1;
+        }
+    } {
+        return false;
+    }
+    end;
+
+def isMarkedInDirection = \d.
+    scanResult <- scan d;
+    ordinalNum <- case scanResult
+        (\_. return (-1))
+        whichHighlightedOrdinal;
+    return $ ordinalNum >= 0;
+    end;
+
+/**
+It's possible we could be one cell away from
+a marked cell after finishing, either due
+to using a directional `drill` command instead of
+`drill down`, or due to an apparent bug which
+does not evaluate the goal condition between the
+`drill` and a `move` command.
+*/
+def moveToMarkedCell = \n.
+    if (n > 0) {
+        isMarkedAhead <- isMarkedInDirection forward;
+        if isMarkedAhead {
+            move;
+            return true;
+        } {
+            turn left;
+            moveToMarkedCell $ n - 1;
+        };
+    } {
+        return false;
+    };
+    end;
+
+/**
+Orient ourselves such that
+a marked cell is behind us.
+*/
+def findMarkBehind = \n.
+    if (n > 0) {
+        isMarkedBehind <- isMarkedInDirection back;
+        if isMarkedBehind {
+            return true;
+        } {
+            turn left;
+            findMarkBehind $ n - 1;
+        };
+    } {
+        return false;
+    };
+    end;
+
+/**
+The cell we're on might be in the middle of a word,
+rather than the end. Determine the orientation of
+the line, then move along it until reaching the end.
+
+Algorithm:
+0. Assumption: we are currently on a marked cell.
+1. Turn in all all four directions to `scan back`
+   for a second marked cell. Stop turning if
+   we encounter one.
+   If none found after 4 turns, abort.
+2. `scan forward` to see if there is a marked cell in
+   the opposite direction.
+   `move` (foward) once if there is. Since the word
+   is only three cells long, this will be the other
+   end of it.
+*/
+def moveToWordExtrema =
+    foundCellBehind <- findMarkBehind 4;
+    if foundCellBehind {
+        isMarkedAhead <- isMarkedInDirection forward;
+        if isMarkedAhead {
+            move;
+        } {};
+    } {};
+    end;
+
+def checkSoln =
+    isMarkedHere <- isMarkedInDirection down;
+    atMarkedCell <- if isMarkedHere {
+        return true;
+    } {
+        moveToMarkedCell 4;
+    };
+
+    if atMarkedCell {
+        moveToWordExtrema;
+        checkDirections 4;
+    } {
+        return false;
+    }
+    end;
+
+as base {checkSoln};
diff --git a/data/scenarios/Challenges/bucket-brigade.yaml b/data/scenarios/Challenges/bucket-brigade.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/bucket-brigade.yaml
@@ -0,0 +1,216 @@
+version: 1
+name: Bucket Brigade
+author: Karl Ostmo
+description: |
+  Convey resources with limited mobility
+creative: false
+robots:
+  - name: base
+    display:
+      char: 'Ω'
+      attr: robot
+    heavy: true
+    dir: [0, 1]
+    devices:
+      - 3D printer
+      - ADT calculator
+      - branch predictor
+      - clock
+      - comparator
+      - counter
+      - dictionary
+      - grabber
+      - hearing aid
+      - lambda
+      - logger
+      - mirror
+      - net
+      - scanner
+      - strange loop
+      - string
+      - workbench
+      - briquette press
+    inventory:
+      - [10, repro kit]
+      - [1, treads]
+  - name: hauler
+    system: true
+    dir: [-1, 0]
+    devices:
+      - furnace
+    display:
+      invisible: false
+      char: '='
+      attr: robot
+    program: |
+      run "scenarios/Challenges/_bucket-brigade/hauler.sw"
+  - name: powerplant
+    system: true
+    dir: [1, 0]
+    display:
+      invisible: false
+      char: '▒'
+      attr: gold
+    program: |
+      run "scenarios/Challenges/_bucket-brigade/powerplant.sw"
+objectives:
+  - id: deliver_coal_lump
+    teaser: Get coal to base
+    goal:
+      - Deliver a "coal lump" to the base.
+      - |
+        To excavate coal from the "lignite mine" (M), a robot needs to
+        `drill` while in posession of a "bucketwheel excavator".
+      - |
+        To assemble the excavator, you'll need to repurpose
+        some "treads".
+        Unfortunately, you have only one set of "treads".
+        You'll have to make do...
+    condition: |
+      as base {has "coal lump"}
+  - goal:
+      - Your base got some coal!
+      - Now fashion more of it into a "coal briquette", and place it
+        so that the hauler (=) can pick it up.
+      - The hauler will periodically stop at the base
+        to check if a load is available.
+    condition: |
+      hauler <- robotnamed "hauler";
+      as hauler {has "coal briquette"}
+    prerequisite: deliver_coal_lump
+solution: |
+  run "scenarios/Challenges/_bucket-brigade/brigade.sw"
+entities:
+  - name: bucketwheel excavator
+    display:
+      char: 'x'
+    description:
+      - A powerful machine designed to remove overburden in mining operations.
+    properties: [known, portable]
+    capabilities: [drill]
+  - name: lignite mine
+    display:
+      attr: copper
+      char: 'M'
+    description:
+      - Requires heavy equipment to remove the overburden.
+    properties: [known]
+  - name: coal lump
+    display:
+      char: 'c'
+    description:
+      - Primitive, sooty fuel.
+    properties: [known, portable]
+  - name: coal briquette
+    display:
+      char: 'q'
+    description:
+      - A compressed, convenient form of coal for consumption and transport.
+    properties: [known, portable]
+  - name: briquette press
+    display:
+      char: 'P'
+    description:
+      - A device to compress coal lumps into briquettes.
+    properties: [known, portable]
+  - name: energy
+    display:
+      char: 'E'
+    description:
+      - The result of burning a coal briquettte.
+    properties: [portable]
+  - name: repro kit
+    display:
+      char: 'k'
+    description:
+      - Kit that can be unpacked into everything a robot needs to reproduce.
+    properties: [known, portable]
+recipes:
+  - in:
+      - [1, repro kit]
+    out:
+      - [1, 3D printer]
+      - [1, ADT calculator]
+      - [1, branch predictor]
+      - [1, calculator]
+      - [1, clock]
+      - [1, comparator]
+      - [1, counter]
+      - [1, dictionary]
+      - [1, grabber]
+      - [1, lambda]
+      - [1, lodestone]
+      - [1, logger]
+      - [1, metal drill]
+      - [1, mirror]
+      - [1, net]
+      - [1, scanner]
+      - [1, solar panel]
+      - [1, string]
+      - [1, strange loop]
+      - [1, welder]
+      - [1, workbench]
+  - in:
+      - [1, treads]
+    out:
+      - [1, bucketwheel excavator]
+  - in:
+      - [1, lignite mine]
+    out:
+      - [1, lignite mine]
+      - [1, coal lump]
+    required:
+      - [1, bucketwheel excavator]
+      - [1, metal drill]
+  - in:
+      - [100, coal lump]
+    out:
+      - [1, coal briquette]
+    required:
+      - [1, briquette press]
+  - in:
+      - [1, coal briquette]
+    out:
+      - [1, energy]
+    required:
+      - [1, furnace]
+
+
+known: [boulder, lignite mine]
+seed: 0
+world:
+  default: [grass]
+  palette:
+    'B': [dirt, null, base]
+    '.': [dirt]
+    '=': [stone]
+    'M': [stone, lignite mine]
+    'H': [stone, null, hauler]
+    'Z': [stone, null, powerplant]
+    'w': [grass]
+    's': [stone]
+    'A': [stone, boulder]
+    '┌': [stone, upper left corner]
+    '┐': [stone, upper right corner]
+    '└': [stone, lower left corner]
+    '┘': [stone, lower right corner]
+    '─': [stone, horizontal wall]
+    '│': [stone, vertical wall]
+  upperleft: [0, 0]
+  map: |-
+    ww...A.AA.A..A.AA....A..ww
+    w..A..A.AA........A......w
+    .AA..A......M....A....A...
+    ....A..A..................
+    .A...A.A..................
+    ..AA......................
+    ...A..A...............┌┐..
+    ..A.................┌┐││..
+    .....A.............┌────┐.
+    ...................│sZss│.
+    .A.................└────┘.
+    .....A......B==========H=.
+    ..........................
+    .............┌┐.┌──────┐..
+    w......A.....└┘.└──────┘..
+    ww........................
diff --git a/data/scenarios/Challenges/friend.yaml b/data/scenarios/Challenges/friend.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/friend.yaml
@@ -0,0 +1,94 @@
+version: 1
+name: Make a Friend
+author: Brent Yorgey
+description: |
+  Win the trust of a furry companion.
+creative: false
+robots:
+  - name: base
+    display:
+      char: 'Ω'
+      attr: robot
+    heavy: true
+    dir: [0, 1]
+    devices:
+      - 3D printer
+      - ADT calculator
+      - antenna
+      - branch predictor
+      - clock
+      - comparator
+      - counter
+      - dictionary
+      - grabber
+      - hearing aid
+      - lambda
+      - logger
+      - mirror
+      - net
+      - scanner
+      - strange loop
+      - string
+      - toolkit
+      - workbench
+    inventory:
+      - [10, ADT calculator]
+      - [10, antenna]
+      - [10, treads]
+      - [10, branch predictor]
+      - [10, fish]
+      - [10, solar panel]
+      - [10, grabber]
+      - [10, lambda]
+      - [10, strange loop]
+      - [10, logger]
+      - [10, clock]
+      - [10, comparator]
+      - [10, calculator]
+  - name: cat
+    system: true
+    dir: [-1, 0]
+    display:
+      invisible: false
+      char: 'c'
+      attr: sand
+    devices:
+      - logger
+    program: |
+      run "scenarios/Challenges/_friend/cat.sw"
+objectives:
+  - goal:
+      - There's a cat wandering around in the field.  Bring it back to
+        your base.  If you give it something it likes, perhaps you can
+        get it to follow you.
+    condition: |
+      c <- robotNamed "cat";
+      catLoc <- as c {whereami};
+      baseLoc <- as base {whereami};
+      return (catLoc == baseLoc)
+solution: |
+  run "scenarios/Challenges/_friend/friend-solution.sw"
+entities:
+  - name: fish
+    display:
+      char: 'f'
+    description:
+      - A smelly fish. Rather unappetizing to a robot.
+    properties: [known, portable]
+
+known: [fish]
+seed: 0
+world:
+  offset: true
+  palette:
+    'Ω': [grass, null, base]
+    '.': [stone]
+    ',': [grass]
+    'c': [grass, null, cat]
+    '*': [grass, flower]
+    '@': [stone, boulder]
+  upperleft: [-20, 2]
+  map: |-
+    c,..,,,,,,..,,,,...,.
+    ,..,,,,,,,...........
+    *,.,@,,,,,,,.....,..Ω
diff --git a/data/scenarios/Challenges/hanoi.yaml b/data/scenarios/Challenges/hanoi.yaml
--- a/data/scenarios/Challenges/hanoi.yaml
+++ b/data/scenarios/Challenges/hanoi.yaml
@@ -57,7 +57,7 @@
       // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
       // if
       //  0. I stand on unlocked X
-      //  1. place noth of me is NOT empty
+      //  1. place north of me is NOT empty
       // then
       //  - lock X
       run "scenarios/Challenges/hanoi-invariant.sw"
@@ -157,7 +157,7 @@
 world:
   default: [grass, null]
   palette:
-    ' ': [grass]
+    ',': [grass]
     '_': [stone]
     'v': [stone, null, base]
     '┌': [stone, upper left corner]
@@ -182,5 +182,5 @@
     │2│.│.│
     │3│.│.│
     └─┴─┴─┘
-     ^ ^ ^ 
-       X   
+    ,^,^,^,
+    ,,,X,,,
diff --git a/data/scenarios/Challenges/ice-cream.yaml b/data/scenarios/Challenges/ice-cream.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/ice-cream.yaml
@@ -0,0 +1,241 @@
+version: 1
+name: Ice Cream Shop
+author: Karl Ostmo
+description: |
+  Serve a demanding customer
+creative: false
+seed: 0
+objectives:
+  - goal:
+      - |
+        Congratulations on the grand opening of your new ice cream shop.
+        You have advertised: "All you can eat, for 1 bitcoin!"
+      - |
+        A customer is approaching the "Counter". They look hungry!
+      - "..."
+      - |
+        Customer: "`give` me a cone, and then I'll tell you how many scoops I want."
+      - "..."
+      - |
+        Oh dear, you've forgotten to stock your shop with a "calculator".
+        Let's hope this order is simple.
+    condition: |
+      customer <- robotnamed "customer";
+      as customer {has "cone"};
+  - goal:
+      - |
+        `give` the customer exactly what they request.
+      - |
+        Perhaps there is something else around the shop that can assist with
+        preparing the order...
+    condition: |
+      as base {
+        has "bitcoin";
+      };
+robots:
+  - name: base
+    dir: [0, 1]
+    devices:
+      - branch predictor
+      - treads
+      - antenna
+      - comparator
+      - workbench
+      - grabber
+      - dictionary
+      - lambda
+      - logger
+      - welder
+      - hearing aid
+      - scanner
+      - strange loop
+    inventory:
+      - [5000, egg]
+      - [5000, milk]
+      - [5000, sugar]
+      - [5000, ice]
+      - [5000, vanilla]
+  - name: customer
+    system: true
+    dir: [0, -1]
+    display:
+      invisible: false
+      attr: 'iron'
+    inventory:
+      - [1, bitcoin]
+    program: |
+      run "scenarios/Challenges/_ice-cream/customer.sw"
+solution: |
+   run "scenarios/Challenges/_ice-cream/solution.sw"
+entities:
+  - name: scoop
+    display:
+      char: '@'
+    description:
+      - A single scoop of vanilla ice cream
+    properties: [portable]
+  - name: cherry
+    display:
+      char: '6'
+      attr: 'red'
+    properties: [portable]
+    description:
+      - A traditional ice cream garnish
+  - name: Counter
+    display:
+      char: '▒'
+      attr: 'sand'
+    properties: [portable, known]
+    capabilities: [count]
+    description:
+      - Where customers are served.
+      - Also... it can count things.
+  - name: cone
+    display:
+      char: 'V'
+      attr: 'sand'
+    properties: [portable]
+    description:
+      - A conical, edible container for ice cream
+  - name: egg
+    display:
+      char: 'e'
+      attr: 'gold'
+    properties: [portable]
+    description:
+      - Organic from local, free-range, meticulously pampered chickens.
+  - name: milk
+    display:
+      char: 'm'
+      attr: 'silver'
+    properties: [portable]
+    description:
+      - Pasteurized, homogenized, commoditized, metabolized.
+  - name: ice
+    display:
+      char: 'i'
+      attr: 'snow'
+    properties: [portable]
+    description:
+      - Ice cream is better cold, don't you think?
+  - name: sugar
+    display:
+      char: 's'
+      attr: 'snow'
+    properties: [portable]
+    description:
+      - Basis of most desserts
+  - name: vanilla
+    display:
+      char: 'v'
+      attr: 'wood'
+    properties: [portable]
+    description:
+      - Traditional ice cream flavoring
+  - name: briefcase
+    display:
+      char: 'B'
+      attr: rubber
+    capabilities: [sum, prod]
+    properties: [portable]
+    description:
+      - Standard business accoutrement.
+      - Particularly useful for `meet`-ings.
+      - Enables the `case` expression.
+  - name: awning1
+    display:
+      char: '▒'
+      attr: flower
+    description:
+      - Decoration
+    properties: [known]
+  - name: awning2
+    display:
+      char: '▒'
+      attr: snow
+    description:
+      - Decoration
+    properties: [known]
+  - name: cone left
+    display:
+      char: '\'
+      attr: wood
+    description:
+      - Decoration
+    properties: [known]
+  - name: cone right
+    display:
+      char: '/'
+      attr: wood
+    description:
+      - Decoration
+    properties: [known]
+  - name: cone top
+    display:
+      char: 'M'
+      attr: wood
+    description:
+      - Decoration
+    properties: [known]
+  - name: cone inside
+    display:
+      char: 'Z'
+      attr: wood
+    description:
+      - Decoration
+    properties: [known]
+  - name: decorative scoop
+    display:
+      char: 'O'
+      attr: snow
+    description:
+      - Decoration
+    properties: [known]
+recipes:
+  - in:
+      - [1, egg]
+      - [1, milk]
+      - [1, sugar]
+      - [1, ice]
+      - [1, vanilla]
+    out:
+      - [1, scoop]
+known: []
+world:
+  default: [grass]
+  upperleft: [0, 0]
+  offset: false
+  palette:
+    '@': [stone, boulder]
+    '.': [grass]
+    ':': [stone]
+    'B': [stone, briefcase]
+    '6': [stone, cherry]
+    'V': [stone, cone]
+    Ω: [stone, null, base]
+    X: [stone, null, customer]
+    'y': [stone, awning1]
+    'z': [stone, awning2]
+    '┌': [stone, upper left corner]
+    '┐': [stone, upper right corner]
+    '└': [stone, lower left corner]
+    '┘': [stone, lower right corner]
+    '─': [stone, horizontal wall]
+    '│': [stone, vertical wall]
+    '\': [stone, cone left]
+    '/': [stone, cone right]
+    'Z': [stone, cone inside]
+    '^': [stone, cone top]
+    'O': [stone, decorative scoop]
+    'x': [stone, Counter]
+  map: |
+    ...X..........
+    ...:..........
+    ...:..........
+    ...:..........
+    ...:.......OO.
+    yzy:yzyz..OOOO
+    ┌─┐x┌──┐..^^^^
+    │B:Ω:V6│..\ZZ/
+    └──────┘...\/.
+    
diff --git a/data/scenarios/Challenges/teleport.yaml b/data/scenarios/Challenges/teleport.yaml
--- a/data/scenarios/Challenges/teleport.yaml
+++ b/data/scenarios/Challenges/teleport.yaml
@@ -58,6 +58,7 @@
 world:
   default: [ice, water]
   palette:
+    ',': [ice, water]
     ' ': [ice, water]
     '~': [ice, wavy water]
     '*': [grass, flower]
@@ -74,9 +75,9 @@
   upperleft: [-1, 3]
   map: |
     ~~   ~         ~    ~
-    ~┌─────┐~  ┌─────┐~  
+    ~┌─────┐~  ┌─────┐~ ,
     ┌┘.....└───┘T...*└┐ ~
-    │_....._   _..λ.._│  
+    │_....._   _..λ.._│ ,
     └┐.....┌───┐*...T┌┘ ~
-    ~└─────┘ ~~└─────┘~  
+    ~└─────┘ ~~└─────┘~ ,
     ~~~   ~        ~    ~
diff --git a/data/scenarios/Challenges/wolf-goat-cabbage.yaml b/data/scenarios/Challenges/wolf-goat-cabbage.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/wolf-goat-cabbage.yaml
@@ -0,0 +1,125 @@
+version: 1
+name: Lake crossing
+author: Karl Ostmo
+description: |
+  Deposit all three items on the eastern shore of the lake.
+creative: false
+robots:
+  - name: base
+    display:
+      char: 'U'
+      attr: wood
+    dir: [0, 1]
+    devices:
+      - ADT calculator
+      - branch predictor
+      - treads
+      - lodestone
+      - compass
+      - comparator
+      - counter
+      - dictionary
+      - grabber
+      - lambda
+      - logger
+      - mirror
+      - net
+      - scanner
+      - strange loop
+      - boat
+  - name: east_detector
+    system: true
+    dir: [0, 1]
+    display:
+      invisible: true
+  - name: west_detector
+    system: true
+    dir: [0, 1]
+    display:
+      invisible: true
+objectives:
+  - goal:
+      - |
+        Ferry all three of the wolf, goat, and cabbage across the lake.
+        However, only one of these can be carried at a time.
+      - |
+        Furthermore, if left unattended together, the wolf will eat the goat,
+        or the goat will eat the cabbage.
+    condition: |
+      run "data/scenarios/Challenges/_wolf-goat-cabbage/together-on-east-bank.sw";
+    prerequisite:
+      previewable: true
+      logic:
+        not:
+          or: 
+            - id: unattended_disaster
+            - id: carrying_two_items
+  - goal:
+      - Carrying two items at once is not allowed!
+    condition: |
+      as base {
+        run "data/scenarios/Challenges/_wolf-goat-cabbage/multi-item-possession.sw";
+      };
+    id: carrying_two_items
+    optional: true
+    hidden: true
+  - id: unattended_disaster
+    teaser: Something was eaten
+    goal:
+      - Leaving an animal alone with its food is not allowed!
+    condition: |
+      run "data/scenarios/Challenges/_wolf-goat-cabbage/unattended-together.sw";
+    optional: true
+    hidden: true    
+solution: |
+  run "scenarios/Challenges/_wolf-goat-cabbage/cross.sw"
+entities:
+  - name: wolf
+    display:
+      char: 'w'
+      attr: silver
+    description:
+      - A wolf. Likes to eat goats.
+    properties: [known, portable]
+  - name: goat
+    display:
+      char: 'g'
+      attr: sand
+    description:
+      - A goat. Likes to eat cabbage.
+    properties: [known, portable]
+  - name: cabbage
+    display:
+      char: 'c'
+      attr: green
+    description:
+      - A cabbage.
+    properties: [known, portable]
+known: [water, boulder]
+seed: 0
+world:
+  default: [blank]
+  palette:
+    'A': [stone, boulder]
+    'B': [stone, boulder, base]
+    '.': [grass]
+    'e': [grass, null, east_detector]
+    '~': [dirt, water]
+    'w': [grass, wolf]
+    'g': [grass, goat]
+    'c': [grass, cabbage, west_detector]
+  upperleft: [0, 0]
+  map: |-
+    AAAAAAAAAAAAAAAAAAAAA
+    AAAAAAAA~~~~~AAAAAAAA
+    AAAAA~~~~~~~~~~~AAAAA
+    AAA~~~~~~~~~~~~~~~AAA
+    AA~~~~~~~~~~~~~~~~~AA
+    Aw~~~~~~~~~~~~~~~~~.A
+    Ag~~~~~~~~~~~~~~~~~.A
+    Ac~~~~~~~~~~~~~~~~~eA
+    AB~~~~~~~~~~~~~~~~~AA
+    AAA~~~~~~~~~~~~~~~AAA
+    AAAAA~~~~~~~~~~~AAAAA
+    AAAAAAAA~~~~~AAAAAAAA
+    AAAAAAAAAAAAAAAAAAAAA
diff --git a/data/scenarios/Challenges/word-search.yaml b/data/scenarios/Challenges/word-search.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Challenges/word-search.yaml
@@ -0,0 +1,387 @@
+version: 1
+name: Word search
+author: Karl Ostmo
+description: |
+  Find the word
+seed: 2
+creative: false
+objectives:
+  - id: prepare_playfield
+    teaser: Wait patiently
+    goal:
+      - |
+        First, wait for the playfield to be set up,
+        and then your path forward will be cleared.
+      - |
+        You can speed this process up with CTRL+x.
+    condition: |
+      as base {knows "capital C"};
+  - goal:
+      - Use the `drill` command (e.g. "drill down" when on top of the
+        intended letter) to mark the sequence of letters that
+        spells COW within the designated playfield.
+      - |
+        The sequence may appear horizontally in either the
+        leftward or rightward direction,
+        or vertically in either the upward or downward direction.
+        Diagonal appearances are not valid.
+    prerequisite: prepare_playfield
+    condition: |
+      /**
+      Algorithm:
+      We only need to check the base's
+      current position: if we find three contiguous highlights,
+      then we know that the player has just completed their
+      third highlight.
+      */
+
+      def whichOrdinal =
+          isC <- ishere "lowercase c";
+          if (isC) {
+              return 0;
+          } {
+              isO <- ishere "lowercase o";
+              if (isO) {
+                  return 1;
+              } {
+                  isW <- ishere "lowercase w";
+                  if (isW) {
+                      return 2;
+                  } {
+                      return (-1);
+                  }
+              }
+          }
+          end;
+
+
+      def whichHighlightedOrdinal = \str.
+          if (str == "lowercase c") {
+              return 0;
+          } {
+              if (str == "lowercase o") {
+                  return 1;
+              } {
+                  if (str == "lowercase w") {
+                      return 2;
+                  } {
+                      return (-1);
+                  }
+              }
+          }
+          end;
+
+      def countConsecutive = \expectedOrdinal. \n.
+
+          thisOrdinal <- whichOrdinal;
+          nextOrdinal <- if (thisOrdinal == expectedOrdinal) {
+              return $ expectedOrdinal + 1;
+          } {
+              return 0;
+          };
+
+          if (nextOrdinal == 3) {
+              return true;
+          } {
+              if (n > 1) {
+                  move;
+                  countConsecutive nextOrdinal $ n - 1;
+              } {
+                  return false;
+              };
+          };
+          end;
+
+      def checkBackAndForth =
+
+          foundBackward <- countConsecutive 0 3;
+          if (foundBackward) {
+              return true;
+          } {
+              turn back;
+              countConsecutive 0 3;
+          }
+          end;
+
+      def checkDirections = \n.
+          if (n > 0) {
+              wasFound <- checkBackAndForth;
+              if wasFound {
+                  return true;
+              } {
+                  turn left;
+                  checkDirections $ n - 1;
+              }
+          } {
+              return false;
+          }
+          end;
+
+      def isMarkedInDirection = \d.
+          scanResult <- scan d;
+          ordinalNum <- case scanResult
+              (\_. return (-1))
+              whichHighlightedOrdinal;
+          return $ ordinalNum >= 0;
+          end;
+
+      /**
+      It's possible we could be one cell away from
+      a marked cell after finishing, either due
+      to using a directional `drill` command instead of
+      `drill down`, or due to an apparent bug which
+      does not evaluate the goal condition between the
+      `drill` and a `move` command.
+      */
+      def moveToMarkedCell = \n.
+          if (n > 0) {
+              isMarkedAhead <- isMarkedInDirection forward;
+              if isMarkedAhead {
+                  move;
+                  return true;
+              } {
+                  turn left;
+                  moveToMarkedCell $ n - 1;
+              };
+          } {
+              return false;
+          };
+          end;
+
+      /**
+      Orient ourselves such that
+      a marked cell is behind us.
+      */
+      def findMarkBehind = \n.
+          if (n > 0) {
+              isMarkedBehind <- isMarkedInDirection back;
+              if isMarkedBehind {
+                  return true;
+              } {
+                  turn left;
+                  findMarkBehind $ n - 1;
+              };
+          } {
+              return false;
+          };
+          end;
+
+      /**
+      The cell we're on might be in the middle of a word,
+      rather than the end. Determine the orientation of
+      the line, then move along it until reaching the end.
+
+      Algorithm:
+      0. Assumption: we are currently on a marked cell.
+      1. Turn in all all four directions to `scan back`
+        for a second marked cell. Stop turning if
+        we encounter one.
+        If none found after 4 turns, abort.
+      2. `scan forward` to see if there is a marked cell in
+        the opposite direction.
+        `move` (foward) once if there is. Since the word
+        is only three cells long, this will be the other
+        end of it.
+      */
+      def moveToWordExtrema =
+          foundCellBehind <- findMarkBehind 4;
+          if foundCellBehind {
+              isMarkedAhead <- isMarkedInDirection forward;
+              if isMarkedAhead {
+                  move;
+              } {};
+          } {};
+          end;
+
+      def checkSoln =
+          isMarkedHere <- isMarkedInDirection down;
+          atMarkedCell <- if isMarkedHere {
+              return true;
+          } {
+              moveToMarkedCell 4;
+          };
+
+          if atMarkedCell {
+              moveToWordExtrema;
+              checkDirections 4;
+          } {
+              return false;
+          }
+          end;
+
+      as base {checkSoln};
+
+robots:
+  - name: base
+    display:
+      attr: flower
+    dir: [-1, 0]
+    devices:
+      - treads
+      - ADT calculator
+      - branch predictor
+      - GPS receiver
+      - clock
+      - comparator
+      - counter
+      - dictionary
+      - lambda
+      - logger
+      - mirror
+      - net
+      - scanner
+      - strange loop
+      - string
+      - highlighter
+    inventory:
+      - [3, ink]
+  - name: lettersetter
+    system: true
+    dir: [1, 0]
+    display:
+      invisible: true
+    inventory:
+      - [376, capital C]
+      - [376, capital O]
+      - [376, capital W]
+    program: |
+      run "scenarios/Challenges/_word-search/create-puzzle.sw"
+solution: |
+  // Hard-coded for seed=2:
+  def doN = \n. \f. if (n > 0) {f; doN (n - 1) f} {}; end;
+  def waitUntilUnblocked =
+    x <- blocked;
+    if x {
+        wait 1;
+        waitUntilUnblocked;
+    } {};
+    end;
+  waitUntilUnblocked;
+  doN 11 move;
+  turn right;
+  doN 2 move;
+  drill down;
+  move;
+  drill down;
+  move;
+  drill down;
+  // run "scenarios/Challenges/_word-search/solution.sw"
+entities:
+  - name: capital C
+    display:
+      char: 'C'
+    description:
+      - C is for California
+  - name: capital O
+    display:
+      char: 'O'
+    description:
+      - O is for Oklahoma
+  - name: capital W
+    display:
+      char: 'W'
+    description:
+      - W is for Washington
+  - name: lowercase c
+    display:
+      char: 'c'
+      attr: gold
+    description:
+      - c is for cantaloupe
+  - name: lowercase o
+    display:
+      char: 'o'
+      attr: gold
+    description:
+      - o is for orange
+  - name: lowercase w
+    display:
+      char: 'w'
+      attr: gold
+    description:
+      - w is for watermelon
+  - name: highlighter
+    display:
+      char: 'P'
+      attr: gold
+    capabilities: [drill]
+    description:
+      - Instrument for marking found words
+    properties: [known, portable]
+  - name: ink
+    display:
+      char: 'K'
+      attr: gold
+    description:
+      - Ink for marking found words
+    properties: [known, portable]
+recipes:
+  - in:
+      - [1, capital C]
+      - [1, ink]
+    out:
+      - [1, lowercase c]
+    required:
+      - [1, highlighter]
+  - in:
+      - [1, capital O]
+      - [1, ink]
+    out:
+      - [1, lowercase o]
+    required:
+      - [1, highlighter]
+  - in:
+      - [1, capital W]
+      - [1, ink]
+    out:
+      - [1, lowercase w]
+    required:
+      - [1, highlighter]
+  - in:
+      - [1, lowercase c]
+    out:
+      - [1, capital C]
+      - [1, ink]
+    required:
+      - [1, highlighter]
+  - in:
+      - [1, lowercase o]
+    out:
+      - [1, capital O]
+      - [1, ink]
+    required:
+      - [1, highlighter]
+  - in:
+      - [1, lowercase w]
+    out:
+      - [1, capital W]
+      - [1, ink]
+    required:
+      - [1, highlighter]
+known: [boulder]
+world:
+  default: [dirt]
+  upperleft: [0, 0]
+  offset: false
+  palette:
+    '@': [stone, boulder]
+    '.': [grass]
+    'a': [grass, null, lettersetter]
+    '#': [dirt]
+    Ω: [stone, null, base]
+  map: |
+    a........................###
+    .........................###
+    .........................###
+    .........................###
+    .........................###
+    .........................###
+    .........................###
+    .........................###
+    .........................###
+    .........................@@@
+    .........................@Ω@
+    .........................@@@
+    .........................###
+    .........................###
+    .........................###
diff --git a/data/scenarios/Fun/00-ORDER.txt b/data/scenarios/Fun/00-ORDER.txt
--- a/data/scenarios/Fun/00-ORDER.txt
+++ b/data/scenarios/Fun/00-ORDER.txt
@@ -1,1 +1,2 @@
 GoL.yaml
+logo-burst.yaml
diff --git a/data/scenarios/Fun/_logo-burst/coordinator.sw b/data/scenarios/Fun/_logo-burst/coordinator.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Fun/_logo-burst/coordinator.sw
@@ -0,0 +1,12 @@
+def forever : cmd unit -> cmd unit = \c. c ; forever c end
+
+def alternate =
+  wait 50;
+  say "Green light";
+  make "bit (1)";
+  wait 100;
+  say "Red light";
+  make "bit (0)";
+  end;
+
+forever alternate;
diff --git a/data/scenarios/Fun/_logo-burst/drone.sw b/data/scenarios/Fun/_logo-burst/drone.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Fun/_logo-burst/drone.sw
@@ -0,0 +1,67 @@
+
+def repeat : int -> cmd unit -> cmd unit =
+  \n. \c. if (n == 0) {} {c ; repeat (n-1) c}
+end
+def abs = \n. if (n < 0) {-n} {n} end
+def elif = \t. \then. \else. {if t then else} end
+def else = \t. t end
+
+def randdir : cmd dir =
+  d <- random 4;
+  return (
+    if (d == 0) {north}
+    $ elif (d == 1) {east}
+    $ elif (d == 2) {south}
+    $ else {west}
+  )
+end;
+
+def wander =
+  d <- randdir;
+  turn d;
+  dist <- random 4;
+  try {repeat dist move} {};
+end;
+
+def disperse =
+  r <- robotnamed "stoplight";
+  greenLight <- as r {has "bit (1)"};
+  if greenLight {
+    wander;
+    disperse;
+  } {};
+  end;
+
+def converge = \loc. \currentLoc.
+
+  let xDist = fst currentLoc - fst loc in
+  let yDist = snd currentLoc - snd loc in
+
+  if (xDist < 0) {
+    turn east;
+  } {
+    if (xDist > 0) {
+      turn west;
+    } {};
+  };
+  repeat (abs xDist) move;
+
+  if (yDist < 0) {
+    turn north;
+  } {
+    if (yDist > 0) {
+      turn south;
+    } {};
+  };
+  repeat (abs yDist) move;
+  end;
+
+def go = \loc.
+  disperse;
+  currentLoc <- whereami;
+  converge loc currentLoc;
+  go loc;
+  end;
+
+loc <- whereami;
+go loc;
diff --git a/data/scenarios/Fun/logo-burst.yaml b/data/scenarios/Fun/logo-burst.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Fun/logo-burst.yaml
@@ -0,0 +1,84 @@
+version: 1
+name: Logo burst
+author: Karl Ostmo
+description: |
+  Animate the Swarm logo
+creative: false
+seed: 0
+robots:
+  # We include a base robot to stabilize the screen at a fixed position.
+  - name: base
+    display:
+      invisible: true
+    dir: [1, 0]
+    devices:
+      - logger
+      - hearing aid
+  - name: botRight
+    system: true
+    display:
+      invisible: false
+    dir: [1, 0]
+    program: |
+      run "scenarios/Fun/_logo-burst/drone.sw"
+  - name: botUp
+    system: true
+    display:
+      invisible: false
+    dir: [0, 1]
+    program: |
+      run "scenarios/Fun/_logo-burst/drone.sw"
+  - name: botLeft
+    system: true
+    display:
+      invisible: false
+    dir: [-1, 0]
+    program: |
+      run "scenarios/Fun/_logo-burst/drone.sw"
+  - name: botDown
+    system: true
+    display:
+      invisible: false
+    dir: [0, -1]
+    program: |
+      run "scenarios/Fun/_logo-burst/drone.sw"
+  - name: stoplight
+    system: true
+    display:
+      invisible: false
+    dir: [0, 1]
+    devices:
+      - lodestone
+    inventory:
+      - [1, bit (0)]
+    program: |
+      run "scenarios/Fun/_logo-burst/coordinator.sw"
+  
+known: [boulder, tree, water, wavy water]
+world:
+  default: [blank]
+  upperleft: [0, 0]
+  offset: false
+  palette:
+    'Ω': [blank, null, base]
+    's': [blank, null, stoplight]
+    '.': [blank]
+    ' ': [blank]
+    '@': [blank, boulder]
+    'T': [blank, tree]
+    '▒': [blank, water]
+    '~': [blank, wavy water]
+    '>': [blank, null, botRight]
+    '^': [blank, null, botUp]
+    '<': [blank, null, botLeft]
+    'v': [blank, null, botDown]
+  map: |
+    s                   v                                     .
+    .                                                         . 
+    . v<^vv<<@   ^^     vv    >^v^T    ^^^v<     <><      v   >
+    T  ^<        >^  <  >>   v>   v<   >T   <v>  <<▒^  @><>   .
+    .   <@v^^>>   @v  >  <T  <v<T<Ω^>   v><v<     << <<T^ v<  . 
+    . >     >>   v@ @@> <<   ~v   <    >~  ^~    >>  @^  >v   .
+    .   >>^v^^^    < ~ T~v    v<   <~   >>T  <v   vv      ▒>  . 
+    .                             ▒                           . 
+    .                   ^      ^     v       >      >         .
diff --git a/data/scenarios/Speedruns/curry.yaml b/data/scenarios/Speedruns/curry.yaml
--- a/data/scenarios/Speedruns/curry.yaml
+++ b/data/scenarios/Speedruns/curry.yaml
@@ -1,10 +1,12 @@
 version: 1
 name: Curry
 author: Brent Yorgey
-description: Race to make a bowl of curry as fast as possible.
+description: Race to make a bowl of curry as quickly as possible.
   See the Swarm wiki for more information on Swarm speedrunning.
 objectives:
-  - condition: as base {has "curry"}
+  - goal:
+      - Make a bowl of curry as quickly as possible!
+    condition: as base {has "curry"}
 robots:
   - name: base
     loc: [0,0]
diff --git a/data/scenarios/Speedruns/forester.yaml b/data/scenarios/Speedruns/forester.yaml
--- a/data/scenarios/Speedruns/forester.yaml
+++ b/data/scenarios/Speedruns/forester.yaml
@@ -4,7 +4,9 @@
 description: Race to harvest 1024 trees as quickly as possible.
   See the Swarm wiki for more information on Swarm speedrunning.
 objectives:
-  - condition: as base {n <- count "tree"; return (n >= 1024)}
+  - goal:
+      - Harvest 1024 trees as quickly as possible!
+    condition: as base {n <- count "tree"; return (n >= 1024)}
 robots:
   - name: base
     loc: [0,0]
diff --git a/data/scenarios/Speedruns/mithril.yaml b/data/scenarios/Speedruns/mithril.yaml
--- a/data/scenarios/Speedruns/mithril.yaml
+++ b/data/scenarios/Speedruns/mithril.yaml
@@ -4,7 +4,9 @@
 description: Race to mine some mithril.
   See the Swarm wiki for more information on Swarm speedrunning.
 objectives:
-  - condition: as base {has "mithril"}
+  - goal:
+      - Mine some mithril as quickly as possible!
+    condition: as base {has "mithril"}
 robots:
   - name: base
     loc: [0,0]
diff --git a/data/scenarios/Testing/00-ORDER.txt b/data/scenarios/Testing/00-ORDER.txt
--- a/data/scenarios/Testing/00-ORDER.txt
+++ b/data/scenarios/Testing/00-ORDER.txt
@@ -13,4 +13,13 @@
 378-objectives.yaml
 684-swap.yaml
 699-movement-fail
+858-inventory
+795-prerequisite
 710-multi-robot.yaml
+920-meet.yaml
+955-heading.yaml
+397-wrong-missing.yaml
+961-custom-capabilities.yaml
+956-GPS.yaml
+958-isempty.yaml
+1034-custom-attributes.yaml
diff --git a/data/scenarios/Testing/1034-custom-attributes.yaml b/data/scenarios/Testing/1034-custom-attributes.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/1034-custom-attributes.yaml
@@ -0,0 +1,134 @@
+version: 1
+name: Rainbow color custom attributes
+description: |
+  Custom attributes with rainbow colors
+creative: false
+attrs:
+  - name: rainbow1
+    fg: "#ffadad"
+  - name: rainbow2
+    fg: "#ffd6a5"
+  - name: rainbow3
+    fg: "#ffff96"
+  - name: rainbow4
+    fg: "#caffbf"
+  - name: rainbow5
+    fg: "#9bf6ff"
+  - name: rainbow6
+    fg: "#a0c4ff"
+  - name: rainbow7
+    fg: "#bdb2ff"
+  - name: redOnYellow
+    fg: "#ff0000"
+    bg: ffff00
+  - name: cyanOnMagenta
+    fg: 00ffff
+    bg: "#ff00ff"
+  - name: italicAndUnderline
+    style:
+      - Italic
+      - Underline
+  - name: boldAndStrikethrough
+    style:
+      - Bold
+      - Strikethrough
+entities:
+  - name: color1
+    display:
+      char: '▒'
+      attr: rainbow1
+    description:
+      - c1
+    properties: [known]
+  - name: color2
+    display:
+      char: '▒'
+      attr: rainbow2
+    description:
+      - c2
+    properties: [known]
+  - name: color3
+    display:
+      char: '▒'
+      attr: rainbow3
+    description:
+      - c3
+    properties: [known]
+  - name: color4
+    display:
+      char: '▒'
+      attr: rainbow4
+    description:
+      - c4
+    properties: [known]
+  - name: color5
+    display:
+      char: '▒'
+      attr: rainbow5
+    description:
+      - c5
+    properties: [known]
+  - name: color6
+    display:
+      char: '▒'
+      attr: rainbow6
+    description:
+      - c6
+    properties: [known]
+  - name: color7
+    display:
+      char: '▒'
+      attr: rainbow7
+    description:
+      - c7
+    properties: [known]
+  - name: redYellow
+    display:
+      char: 'R'
+      attr: redOnYellow
+    description:
+      - Red on Yellow
+    properties: [known]
+  - name: cyanMagenta
+    display:
+      char: 'C'
+      attr: cyanOnMagenta
+    description:
+      - Cyan on Magenta
+    properties: [known]
+  - name: italicUnderline
+    display:
+      char: 'X'
+      attr: italicAndUnderline
+    description:
+      - Italic and Underline
+    properties: [known]
+  - name: boldStrikethrough
+    display:
+      char: 'X'
+      attr: boldAndStrikethrough
+    description:
+      - Bold and Strikethrough
+    properties: [known]
+robots: []
+world:
+  default: [blank]
+  palette:
+    '.': [blank]
+    '1': [blank, color1]
+    '2': [blank, color2]
+    '3': [blank, color3]
+    '4': [blank, color4]
+    '5': [blank, color5]
+    '6': [blank, color6]
+    '7': [blank, color7]
+    'R': [blank, redYellow]
+    'C': [blank, cyanMagenta]
+    'I': [blank, italicUnderline]
+    'B': [blank, boldStrikethrough]
+  upperleft: [0, 0]
+  map: |-
+    .1234567..R.....II.
+    .1234567..R........
+    .1234567.....C..BB.
+    .1234567.....C.....
diff --git a/data/scenarios/Testing/201-require/201-require-device-creative.yaml b/data/scenarios/Testing/201-require/201-require-device-creative.yaml
--- a/data/scenarios/Testing/201-require/201-require-device-creative.yaml
+++ b/data/scenarios/Testing/201-require/201-require-device-creative.yaml
@@ -8,10 +8,10 @@
       try {
         r <- robotNumbered 1;
         p <- as r {whereami};
-        boatInstalled <- as r {installed "boat"};
+        boatEquipped <- as r {equipped "boat"};
         b1 <- as r {count "boat"};
         b0 <- as base {count "boat"};
-        return (p == (2,0) && b0 == 0 && boatInstalled && b1 == 0);
+        return (p == (2,0) && b0 == 0 && boatEquipped && b1 == 0);
       } { return false }
 creative: true
 solution: |
diff --git a/data/scenarios/Testing/201-require/201-require-device-creative1.yaml b/data/scenarios/Testing/201-require/201-require-device-creative1.yaml
--- a/data/scenarios/Testing/201-require/201-require-device-creative1.yaml
+++ b/data/scenarios/Testing/201-require/201-require-device-creative1.yaml
@@ -8,10 +8,10 @@
       try {
         r <- robotNumbered 1;
         p <- as r {whereami};
-        boatInstalled <- as r {installed "boat"};
+        boatEquipped <- as r {equipped "boat"};
         b1 <- as r {count "boat"};
         b0 <- as base {count "boat"};
-        return (p == (2,0) && b0 == 1 && boatInstalled && b1 == 0);
+        return (p == (2,0) && b0 == 1 && boatEquipped && b1 == 0);
       } { return false }
 creative: true
 solution: |
diff --git a/data/scenarios/Testing/201-require/201-require-device.yaml b/data/scenarios/Testing/201-require/201-require-device.yaml
--- a/data/scenarios/Testing/201-require/201-require-device.yaml
+++ b/data/scenarios/Testing/201-require/201-require-device.yaml
@@ -8,10 +8,10 @@
       try {
         r <- robotNumbered 1;
         p <- as r {whereami};
-        boatInstalled <- as r {installed "boat"};
+        boatEquipped <- as r {equipped "boat"};
         b1 <- as r {count "boat"};
         b0 <- as base {count "boat"};
-        return (p == (2,0) && b0 == 0 && boatInstalled && b1 == 0);
+        return (p == (2,0) && b0 == 0 && boatEquipped && b1 == 0);
       } { return false }
 solution: |
   build {require "boat"; move; require "boat"; move}
diff --git a/data/scenarios/Testing/201-require/533-reprogram-simple.yaml b/data/scenarios/Testing/201-require/533-reprogram-simple.yaml
--- a/data/scenarios/Testing/201-require/533-reprogram-simple.yaml
+++ b/data/scenarios/Testing/201-require/533-reprogram-simple.yaml
@@ -1,7 +1,7 @@
 version: 1
-name: Install devices while reprogramming
+name: Equip devices while reprogramming
 description: |
-  While executing 'reprogram', we should install any required devices which
+  While executing 'reprogram', we should equip any required devices which
   the target robot doesn't have.
   https://github.com/swarm-game/swarm/pulls/533
 objectives:
@@ -14,13 +14,13 @@
 
         fred <- robotNamed "fred";
         p <- as fred {whereami};
-        boatInstalled <- as fred {installed "boat"};
-        drillInstalled <- as fred {installed "metal drill"};
-        solarInstalled <- as fred {installed "solar panel"};
-        treadsInstalled <- as fred {installed "treads"};
+        boatEquipped <- as fred {equipped "boat"};
+        drillEquipped <- as fred {equipped "metal drill"};
+        solarEquipped <- as fred {equipped "solar panel"};
+        treadsEquipped <- as fred {equipped "treads"};
 
         return (p == (2,0)
-                && boatInstalled && drillInstalled && solarInstalled && treadsInstalled
+                && boatEquipped && drillEquipped && solarEquipped && treadsEquipped
                 && base_boats == 1 && base_solar == 1
                 && base_treads == 1 && base_drills == 1
                );
diff --git a/data/scenarios/Testing/201-require/533-reprogram.yaml b/data/scenarios/Testing/201-require/533-reprogram.yaml
--- a/data/scenarios/Testing/201-require/533-reprogram.yaml
+++ b/data/scenarios/Testing/201-require/533-reprogram.yaml
@@ -1,7 +1,7 @@
 version: 1
-name: Install devices + entities while reprogramming
+name: Equip devices + entities while reprogramming
 description: |
-  While executing 'reprogram', we should install any required devices and
+  While executing 'reprogram', we should equip any required devices and
   give required entities which the target robot doesn't have.
   https://github.com/swarm-game/swarm/pulls/533
 objectives:
@@ -15,11 +15,11 @@
 
         fred <- robotNamed "fred";
         p <- as fred {whereami};
-        boatInstalled <- as fred {installed "boat"};
-        drillInstalled <- as fred {installed "metal drill"};
+        boatEquipped <- as fred {equipped "boat"};
+        drillEquipped <- as fred {equipped "metal drill"};
         fred_rocks <- as fred {count "rock"};
 
-        return (p == (2,0) && boatInstalled && drillInstalled
+        return (p == (2,0) && boatEquipped && drillEquipped
                 && base_boats == 1 && base_solar == 1
                 && base_treads == 1 && base_drills == 1
                 && base_rocks == 42 && fred_rocks == 8
diff --git a/data/scenarios/Testing/378-objectives.yaml b/data/scenarios/Testing/378-objectives.yaml
--- a/data/scenarios/Testing/378-objectives.yaml
+++ b/data/scenarios/Testing/378-objectives.yaml
@@ -3,7 +3,9 @@
 description: |
   First, get some trees; then, use them to build a harvester.
 objectives:
-  - goal:
+  - id: get_trees
+    teaser: Get trees
+    goal:
       - Your first goal is to get three trees.
     condition: |
       try {
@@ -14,6 +16,7 @@
       - Nice job.  Now, build a harvester.
     condition: |
       try { as base {has "harvester"} } {return false}
+    prerequisite: get_trees
 solution: |
   build {turn right; move; move; grab; move; grab; move; grab; turn back; move; move; move; move};
   wait 16;
diff --git a/data/scenarios/Testing/394-build-drill.yaml b/data/scenarios/Testing/394-build-drill.yaml
--- a/data/scenarios/Testing/394-build-drill.yaml
+++ b/data/scenarios/Testing/394-build-drill.yaml
@@ -9,7 +9,7 @@
       try {
         as base {l <- has "detonator"; return (not l)}
       } { return false }
-# When testing, add `s <- build {...}; n <- as s {whoami}; log n` 
+# When testing, add `s <- build {...}; n <- as s {whoami}; log n`
 solution: |
   def forever = \c. c ; forever c end;
   def unblock = try {drill forward} {} end;
@@ -18,7 +18,13 @@
   r <- build {
     wait 2;
     log "Hi, I am builder";
-    forever (build {log "Hi, I am pusher"; turn forward; forever push}; log "- robot built")
+    forever (
+      build {
+        log "Hi, I am pusher";
+        forever push
+      };
+      log "- robot built"
+    )
   };
   wait 10;
   place "detonator";
diff --git a/data/scenarios/Testing/397-wrong-missing.yaml b/data/scenarios/Testing/397-wrong-missing.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/397-wrong-missing.yaml
@@ -0,0 +1,38 @@
+version: 1
+name: Test issue 397 (wrong device reported missing)
+description: |
+  A scenario designed to trigger issue 397, where in certain
+  situations involving a capability with no candidate devices,
+  an incorrect error message was generated reporting some other
+  device to be missing.
+  https://github.com/swarm-game/swarm/issues/397
+objectives:
+  - condition: |
+      t <- time; return (t == 2)
+    goal:
+    - |
+      This is a dummy condition that just ensures the base has had
+      time to run the problematic `build` command.  The scenario
+      *should* generate an error message; what we really care about is
+      whether the generated error message is correct, which is checked
+      in test/integration/Main.hs .
+solution: |
+  build {move; turn right; loc <- whereami}
+robots:
+  - name: base
+    dir: [0,1]
+    devices:
+      - 3D printer
+      - logger
+    inventory:
+      - [1, treads]
+      - [1, solar panel]
+world:
+  default: [blank]
+  palette:
+    'Ω': [grass, null, base]
+    '.': [grass]
+  upperleft: [0,1]
+  map: |
+    .
+    Ω
diff --git a/data/scenarios/Testing/562-lodestone.sw b/data/scenarios/Testing/562-lodestone.sw
--- a/data/scenarios/Testing/562-lodestone.sw
+++ b/data/scenarios/Testing/562-lodestone.sw
@@ -32,10 +32,8 @@
 until (ishere "lodestone") {grab};
 
 // get two bit (0)
-// TODO: require should not be necessary
 build {
   log "Hi!";
-  require "branch predictor";
   repeat (
     log "I am going for a bit";
     turn east; m2; x <- until (ishere "bit (0)") {harvest}; turn back; m2; place x;
diff --git a/data/scenarios/Testing/562-lodestone.yaml b/data/scenarios/Testing/562-lodestone.yaml
--- a/data/scenarios/Testing/562-lodestone.yaml
+++ b/data/scenarios/Testing/562-lodestone.yaml
@@ -27,6 +27,7 @@
       - branch predictor
     inventory:
       - [10, drill]
+      - [10, lambda]
       - [10, logger]
       - [10, compass]
       - [10, treads]
diff --git a/data/scenarios/Testing/795-prerequisite/00-ORDER.txt b/data/scenarios/Testing/795-prerequisite/00-ORDER.txt
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/795-prerequisite/00-ORDER.txt
@@ -0,0 +1,4 @@
+795-prerequisite-or.yaml
+795-prerequisite-and.yaml
+795-prerequisite-mutually-exclusive.yaml
+795-prerequisite-cycle-with-not.yaml
diff --git a/data/scenarios/Testing/795-prerequisite/795-prerequisite-and.yaml b/data/scenarios/Testing/795-prerequisite/795-prerequisite-and.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/795-prerequisite/795-prerequisite-and.yaml
@@ -0,0 +1,50 @@
+version: 1
+name: |
+  Prerequisite objectives: AND
+description: |
+  Complete an objective with a prerequisite entailing both of two other objectives.
+objectives:
+  - goal:
+      - Achieve both of two other objectives
+    condition: |
+      return true;
+    prerequisite:
+      previewable: true
+      logic:
+        and:
+          - have_furnace
+          - have_gear
+  - id: have_furnace
+    goal:
+      - Make a "furnace".
+    condition: |
+      as base {has "furnace"};
+    optional: true
+  - id: have_gear
+    goal:
+      - Make a "wooden gear".
+    condition: |
+      as base {has "wooden gear"};
+    optional: true
+solution: |
+  make "furnace";
+  make "wooden gear";
+robots:
+  - name: base
+    display:
+      char: 'Ω'
+      attr: robot
+    dir: [0, 1]
+    devices:
+      - workbench
+      - grabber
+    inventory:
+      - [2, board]
+      - [5, rock]
+world:
+  default: [blank]
+  palette:
+    'x': [grass, null, base]
+  upperleft: [0, 0]
+  map: |
+    x
diff --git a/data/scenarios/Testing/795-prerequisite/795-prerequisite-cycle-with-not.yaml b/data/scenarios/Testing/795-prerequisite/795-prerequisite-cycle-with-not.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/795-prerequisite/795-prerequisite-cycle-with-not.yaml
@@ -0,0 +1,42 @@
+version: 1
+name: |
+  Prerequisite objectives: non-dependency cycle due to the NOT
+description: |
+  This should NOT be rejected by the parser.
+  The two objectives do reference each other,
+  but the NOT is a distinct reference from the non-negated goal.
+objectives:
+  - id: have_furnace
+    goal:
+      - Make a "furnace".
+    condition: |
+      as base {has "furnace"};
+    prerequisite:
+      not: have_gear
+  - id: have_gear
+    goal:
+      - Make a "wooden gear".
+    condition: |
+      as base {has "wooden gear"};
+    prerequisite: have_furnace
+solution: |
+  make "wooden gear";
+  make "furnace";
+robots:
+  - name: base
+    display:
+      char: 'Ω'
+      attr: robot
+    dir: [0, 1]
+    devices:
+      - workbench
+    inventory:
+      - [2, board]
+      - [5, rock]
+world:
+  default: [blank]
+  palette:
+    'x': [grass, null, base]
+  upperleft: [0, 0]
+  map: |
+    x
diff --git a/data/scenarios/Testing/795-prerequisite/795-prerequisite-mutually-exclusive.yaml b/data/scenarios/Testing/795-prerequisite/795-prerequisite-mutually-exclusive.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/795-prerequisite/795-prerequisite-mutually-exclusive.yaml
@@ -0,0 +1,61 @@
+version: 1
+name: |
+  Mutually exclusive prerequisites
+description: |
+  Complete an objective with a prerequisite of either of two other objectives.
+objectives:
+  - goal:
+      - Either obtain a "furnace" WITHOUT a "flower", or obtain both a "wooden gear" and "flower".
+    condition: |
+      return true;
+    prerequisite:
+      previewable: true
+      logic:
+        or:
+          - and:
+            - id: have_furnace
+            - not:
+                id: have_flower
+          - and:
+            - id: have_gear
+            - id: have_flower
+  - id: have_furnace
+    goal:
+      - Make a "furnace".
+    condition: |
+      as base {has "furnace"};
+    
+    optional: true
+  - id: have_gear
+    goal:
+      - Make a "wooden gear".
+    condition: |
+      as base {has "wooden gear"};
+    optional: true
+  - id: have_flower
+    goal:
+      - Possess a "flower".
+    condition: |
+      as base {has "flower"};
+    optional: true
+solution: |
+  make "furnace"
+robots:
+  - name: base
+    display:
+      char: 'Ω'
+      attr: robot
+    dir: [0, 1]
+    devices:
+      - workbench
+      - grabber
+    inventory:
+      - [2, board]
+      - [5, rock]
+world:
+  default: [blank]
+  palette:
+    'x': [grass, flower, base]
+  upperleft: [0, 0]
+  map: |
+    x
diff --git a/data/scenarios/Testing/795-prerequisite/795-prerequisite-or.yaml b/data/scenarios/Testing/795-prerequisite/795-prerequisite-or.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/795-prerequisite/795-prerequisite-or.yaml
@@ -0,0 +1,49 @@
+version: 1
+name: |
+  Prerequisite objectives: OR
+description: |
+  Complete an objective with a prerequisite of either of two other objectives.
+objectives:
+  - goal:
+      - Achieve one of two other objectives
+    condition: |
+      return true;
+    prerequisite:
+      previewable: true
+      logic:
+        or:
+          - id: have_furnace
+          - id: have_gear
+  - id: have_furnace
+    goal:
+      - Make a "furnace".
+    condition: |
+      as base {has "furnace"};
+    optional: true
+  - id: have_gear
+    goal:
+      - Make a "wooden gear".
+    condition: |
+      as base {has "wooden gear"};
+    optional: true
+solution: |
+  make "wooden gear"
+robots:
+  - name: base
+    display:
+      char: 'Ω'
+      attr: robot
+    dir: [0, 1]
+    devices:
+      - workbench
+      - grabber
+    inventory:
+      - [2, board]
+      - [5, rock]
+world:
+  default: [blank]
+  palette:
+    'x': [grass, flower, base]
+  upperleft: [0, 0]
+  map: |
+    x
diff --git a/data/scenarios/Testing/858-inventory/00-ORDER.txt b/data/scenarios/Testing/858-inventory/00-ORDER.txt
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/858-inventory/00-ORDER.txt
@@ -0,0 +1,3 @@
+858-possession-objective.yaml
+858-nonpossession-objective.yaml
+858-counting-objective.yaml
diff --git a/data/scenarios/Testing/858-inventory/858-counting-objective.yaml b/data/scenarios/Testing/858-inventory/858-counting-objective.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/858-inventory/858-counting-objective.yaml
@@ -0,0 +1,36 @@
+version: 1
+name: Evaluate count of an item
+description: |
+  The goal criteria is exactly the same as
+  858-possession-objective but evaluated
+  with the "count" function instead.
+creative: false
+objectives:
+  - goal:
+      - |
+        Get 1 tree.
+    condition: |-
+      as base {
+        itemCount <- count "tree";
+        return $ itemCount > 0;
+      };
+robots:
+  - name: base
+    dir: [0, 1]
+    devices:
+      - treads
+      - scanner
+      - grabber
+solution: |
+  move;
+  grab;
+known: [tree]
+world:
+  default: [blank]
+  palette:
+    'B': [grass, null, base]
+    'w': [grass, tree]
+  upperleft: [0, 0]
+  map: |-
+    w
+    B
diff --git a/data/scenarios/Testing/858-inventory/858-nonpossession-objective.yaml b/data/scenarios/Testing/858-inventory/858-nonpossession-objective.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/858-inventory/858-nonpossession-objective.yaml
@@ -0,0 +1,35 @@
+version: 1
+name: Evaluate nonpossession of an item
+description: |
+  Opposite test case of 858-possession-objective
+creative: false
+objectives:
+  - goal:
+      - |
+        Get rid of 1 tree.
+    condition: |-
+      as base {
+        x <- has "tree";
+        return $ not x;
+      };
+robots:
+  - name: base
+    dir: [0, 1]
+    devices:
+      - treads
+      - scanner
+      - grabber
+    inventory:
+      - [1, tree]
+solution: |
+  place "tree";
+known: [tree]
+world:
+  default: [blank]
+  palette:
+    'B': [grass, null, base]
+    'w': [grass]
+  upperleft: [0, 0]
+  map: |-
+    w
+    B
diff --git a/data/scenarios/Testing/858-inventory/858-possession-objective.yaml b/data/scenarios/Testing/858-inventory/858-possession-objective.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/858-inventory/858-possession-objective.yaml
@@ -0,0 +1,33 @@
+version: 1
+name: Evaluate possession of an item
+description: |
+  test case
+creative: false
+objectives:
+  - goal:
+      - |
+        Get 1 tree.
+    condition: |-
+      as base {
+        has "tree";
+      };
+robots:
+  - name: base
+    dir: [0, 1]
+    devices:
+      - treads
+      - scanner
+      - grabber
+solution: |
+  move;
+  grab;
+known: [tree]
+world:
+  default: [blank]
+  palette:
+    'B': [grass, null, base]
+    'w': [grass, tree]
+  upperleft: [0, 0]
+  map: |-
+    w
+    B
diff --git a/data/scenarios/Testing/920-meet.yaml b/data/scenarios/Testing/920-meet.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/920-meet.yaml
@@ -0,0 +1,52 @@
+version: 1
+name: Test meet and meetAll commands
+description: |
+  Make sure meet prefers a robot on the same cell, and test meetAll
+  by giving everyone a boat.
+objectives:
+  - condition: |
+      r0 <- robotNamed "other0";
+      b0 <- as r0 { has "boat" };
+      teleport self (0,0);
+      b1 <- meetAll (\b. \r. b0 <- as r {has "boat"}; return (b && b0)) true;
+      n2 <- as r0 { count "boat" };
+      return (b0 && b1 && (n2 == 2))
+solution: |
+  mr0 <- meet;
+  case mr0 (\_. return ()) (\r0. give r0 "boat");
+  meetAll (\_. \r. give r "boat") ()
+robots:
+  - name: base
+    loc: [0,0]
+    dir: [1,0]
+    devices:
+      - logger
+      - antenna
+      - ADT calculator
+      - grabber
+    inventory:
+      - [7, boat]
+  - name: other0
+    loc: [0,0]
+    dir: [1,0]
+  - name: other
+    dir: [1,0]
+world:
+  default: [blank]
+  palette:
+    '.': [grass]
+    'Ω': [grass, null]
+    'o': [grass, null, other]
+    '┌': [stone, upper left corner]
+    '┐': [stone, upper right corner]
+    '└': [stone, lower left corner]
+    '┘': [stone, lower right corner]
+    '─': [stone, horizontal wall]
+    '│': [stone, vertical wall]
+  upperleft: [-2, 2]
+  map: |
+    ┌───┐
+    │.o.│
+    │oΩo│
+    │.o.│
+    └───┘
diff --git a/data/scenarios/Testing/955-heading.yaml b/data/scenarios/Testing/955-heading.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/955-heading.yaml
@@ -0,0 +1,28 @@
+version: 1
+name: Test heading command
+description: |
+  Test the `heading` command.
+  https://github.com/swarm-game/swarm/issues/955
+objectives:
+  - condition: |
+      loc <- as base {whereami};
+      h <- as base {heading};
+      return $ loc == (1,-1) && h == north
+solution: |
+  h <- heading; turn east; move; move; turn right; move; move; turn h
+robots:
+  - name: base
+    dir: [0,1]
+    devices:
+      - treads
+      - compass
+world:
+  default: [blank]
+  palette:
+    '^': [grass, null, base]
+    '.': [grass]
+  upperleft: [-1, 1]
+  map: |
+    ^..
+    ...
+    ...
diff --git a/data/scenarios/Testing/956-GPS.yaml b/data/scenarios/Testing/956-GPS.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/956-GPS.yaml
@@ -0,0 +1,66 @@
+version: 1
+name: Test GPS receiver device
+description: |
+  Test the `whereami` command and GPS receiver device.
+  https://github.com/swarm-game/swarm/issues/956
+objectives:
+  - condition: |
+      loc <- as base {whereami};
+      return $ loc == (0,0)
+    goal:
+      - |
+        The goal is to move back to the origin after being
+        unceremoniously teleported to a random location.  The only way
+        to do this is to use a GPS receiver device to learn the
+        current location and use the result to decide how to move.
+solution: |
+  def x = \n. \c. if (n == 0) {} {c ; x (n-1) c} end;
+  def abs = \n. if (n<0) {-n} {n} end;
+  wait 2;
+  loc <- whereami;
+  turn (if (fst loc < 0) {east} {west}); x (abs (fst loc)) move;
+  turn (if (snd loc < 0) {north} {south}); x (abs (snd loc)) move;
+robots:
+  - name: base
+    dir: [0,1]
+    loc: [-5,5]
+    devices:
+      - clock
+      - dictionary
+      - GPS receiver
+      - treads
+      - compass
+      - ADT calculator
+      - strange loop
+      - comparator
+      - calculator
+      - branch predictor
+      - logger
+  - name: teleporter
+    dir: [0,1]
+    loc: [0,0]
+    system: true
+    display:
+      invisible: true
+    program: |
+      x <- random 11;
+      y <- random 11;
+      teleport base (x-5, y-5)
+world:
+  default: [blank]
+  palette:
+    '.': [grass]
+  upperleft: [-5, 5]
+  map: |
+    ...........
+    ...........
+    ...........
+    ...........
+    ...........
+    ...........
+    ...........
+    ...........
+    ...........
+    ...........
+    ...........
+
diff --git a/data/scenarios/Testing/958-isempty.yaml b/data/scenarios/Testing/958-isempty.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/958-isempty.yaml
@@ -0,0 +1,57 @@
+version: 1
+name: Test isempty
+description: |
+  Test the `isempty` command.
+objectives:
+  - condition: |
+      as base {has "thneed"}
+    goal:
+      - |
+        The goal is to pick up the thing right after the empty
+        spot in the row.
+solution: |
+  def find_thing =
+    e <- isempty;
+    move;
+    if e {} {find_thing}
+  end;
+  find_thing;
+  grab
+robots:
+  - name: base
+    dir: [1,0]
+    devices:
+      - scanner
+      - branch predictor
+      - grabber
+      - strange loop
+      - lambda
+      - dictionary
+      - logger
+      - treads
+      - solar panel
+entities:
+  - name: thneed
+    display:
+      attr: gold
+      char: 'Y'
+    description:
+    - A thing that everyone needs!
+    properties: [portable]
+world:
+  default: [blank]
+  palette:
+    '.': [grass]
+    '>': [grass, tree, base]
+    'o': [grass, rock]
+    '$': [grass, LaTeX]
+    'i': [grass, cotton]
+    '0': [grass, bit (0)]
+    '1': [grass, bit (1)]
+    'R': [grass, pixel (R)]
+    'G': [grass, pixel (G)]
+    'B': [grass, pixel (B)]
+    'Y': [grass, thneed]
+  upperleft: [-5, 5]
+  map: |
+    >o$i01R.YGB
diff --git a/data/scenarios/Testing/961-custom-capabilities.yaml b/data/scenarios/Testing/961-custom-capabilities.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/961-custom-capabilities.yaml
@@ -0,0 +1,42 @@
+version: 1
+name: Test issue 961 (custom entities providing capabilities)
+description: |
+  Test that custom entities providing a capability are considered
+  when equipping a new robot.
+  https://github.com/swarm-game/swarm/issues/961
+objectives:
+  - condition: |
+      mover <- robotNumbered 1;
+      as mover { loc <- whereami; return $ loc == (0,1) }
+    goal:
+    - |
+      Get a robot to (0,1)!
+solution: |
+  build {move}
+entities:
+  - name: wheels
+    display:
+      attr: device
+      char: 'o'
+    description:
+    - A non-traditional means of locomotion.
+    properties: [known, portable]
+    capabilities: [move]
+robots:
+  - name: base
+    dir: [0,1]
+    devices:
+      - 3D printer
+      - logger
+    inventory:
+      - [1, wheels]
+      - [1, solar panel]
+world:
+  default: [blank]
+  palette:
+    'Ω': [grass, null, base]
+    '.': [grass]
+  upperleft: [0,1]
+  map: |
+    .
+    Ω
diff --git a/data/scenarios/Testing/_Validation/795-prerequisite-cycle.yaml b/data/scenarios/Testing/_Validation/795-prerequisite-cycle.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/_Validation/795-prerequisite-cycle.yaml
@@ -0,0 +1,38 @@
+version: 1
+name: |
+  Prerequisite objectives: dependency cycle
+description: |
+  This should be rejected by the parser.
+objectives:
+  - id: have_furnace
+    goal:
+      - Make a "furnace".
+    condition: |
+      as base {has "furnace"};
+    prerequisite: have_gear
+  - id: have_gear
+    goal:
+      - Make a "wooden gear".
+    condition: |
+      as base {has "wooden gear"};
+    prerequisite: have_furnace
+solution: |
+  make "wooden gear"
+robots:
+  - name: base
+    display:
+      char: 'Ω'
+      attr: robot
+    dir: [0, 1]
+    devices:
+      - workbench
+    inventory:
+      - [2, board]
+      - [5, rock]
+world:
+  default: [blank]
+  palette:
+    'x': [grass, null, base]
+  upperleft: [0, 0]
+  map: |
+    x
diff --git a/data/scenarios/Testing/_Validation/795-prerequisite-nonexistent-reference.yaml b/data/scenarios/Testing/_Validation/795-prerequisite-nonexistent-reference.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/_Validation/795-prerequisite-nonexistent-reference.yaml
@@ -0,0 +1,38 @@
+version: 1
+name: |
+  Prerequisite objectives: Nonexistent reference
+description: |
+  This scenario should be unparseable due to typo "shave_furnace" vs "have_furnace"
+objectives:
+  - goal:
+      - Achieve one of two other objectives
+    condition: |
+      return true;
+    prerequisite: shave_furnace
+  - id: have_furnace
+    goal:
+      - Make a "furnace".
+    condition: |
+      as base {has "furnace"};
+    optional: true
+solution: |
+  make "furnace"
+robots:
+  - name: base
+    display:
+      char: 'Ω'
+      attr: robot
+    dir: [0, 1]
+    devices:
+      - workbench
+      - grabber
+    inventory:
+      - [2, board]
+      - [5, rock]
+world:
+  default: [blank]
+  palette:
+    'x': [grass, flower, base]
+  upperleft: [0, 0]
+  map: |
+    x
diff --git a/data/scenarios/Testing/_Validation/795-prerequisite-self-reference.yaml b/data/scenarios/Testing/_Validation/795-prerequisite-self-reference.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Testing/_Validation/795-prerequisite-self-reference.yaml
@@ -0,0 +1,31 @@
+version: 1
+name: |
+  Prerequisite objectives: dependency cycle (self-reference)
+description: |
+  This should be rejected by the parser.
+objectives:
+  - id: have_furnace
+    goal:
+      - Make a "furnace".
+    condition: |
+      as base {has "furnace"};
+    prerequisite: have_furnace
+solution: |
+  make "furnace"
+robots:
+  - name: base
+    display:
+      char: 'Ω'
+      attr: robot
+    dir: [0, 1]
+    devices:
+      - workbench
+    inventory:
+      - [5, rock]
+world:
+  default: [blank]
+  palette:
+    'x': [grass, null, base]
+  upperleft: [0, 0]
+  map: |
+    x
diff --git a/data/scenarios/Tutorials/00-ORDER.txt b/data/scenarios/Tutorials/00-ORDER.txt
--- a/data/scenarios/Tutorials/00-ORDER.txt
+++ b/data/scenarios/Tutorials/00-ORDER.txt
@@ -5,9 +5,9 @@
 place.yaml
 types.yaml
 type-errors.yaml
-bind.yaml
-install.yaml
+equip.yaml
 build.yaml
+bind2.yaml
 crash.yaml
 scan.yaml
 def.yaml
diff --git a/data/scenarios/Tutorials/bind.yaml b/data/scenarios/Tutorials/bind.yaml
deleted file mode 100644
--- a/data/scenarios/Tutorials/bind.yaml
+++ /dev/null
@@ -1,81 +0,0 @@
-version: 1
-name: Bind notation
-description: |
-  Learn about command return types and how to bind the results.
-objectives:
-  - goal:
-      - |
-        Every command returns a value.  However, some simple commands, like
-        `move`, do not have any meaningful
-        value to return.  Swarm has a special type, `unit`, with only one value,
-        called `()`. Since there is only one possible value of type
-        `unit`, returning it does not convey any information.
-        Thus, the type of `move` is `cmd unit`.
-      - |
-        Other commands do return a nontrivial value after executing.
-        For example, `grab` has type
-        `cmd text`, and returns the name of the
-        grabbed entity as a text value. Try it:
-      - |
-        move; grab
-      - |
-        To use the result of a command later, you need bind notation, which
-        consists of a variable name and a leftwards-pointing arrow
-        before the command.  For example:
-      - |
-        move; t <- grab; place t
-      - |
-        In the above example, the result returned by `grab` is assigned
-        to the variable name `t`, which can then be used later.
-        This could be useful, for example, if you do not care what you
-        grabbed and just want to move it to another cell, or if you
-        are not sure of the name of the thing being grabbed.
-      - Once you are done experimenting, do `place "Win"` to finish this challenge.
-        (Note, you might need to `grab` the entity you are standing on
-        or move to an empty cell first, since each cell can only
-        contain at most one entity.)
-    condition: |
-      try {
-        w <- as base {has "Win"};
-        return (not w);
-      } { return false }
-solution: |
-  place "Win"
-entities:
-  - name: Win
-    display:
-      attr: device
-      char: 'W'
-    description:
-      - Do `place "Win"` once you are done with this challenge.
-      - You might need to `grab` the entity you are standing on
-        or move to an empty cell first.
-    properties: [known, portable]
-robots:
-  - name: base
-    dir: [1,0]
-    devices:
-      - treads
-      - grabber
-      - logger
-      - compass
-      - dictionary
-    inventory:
-      - [1, Win]
-      - [0, tree]
-world:
-  default: [blank]
-  palette:
-    '>': [grass, null, base]
-    'T': [grass, tree]
-    '┌': [stone, upper left corner]
-    '┐': [stone, upper right corner]
-    '└': [stone, lower left corner]
-    '┘': [stone, lower right corner]
-    '─': [stone, horizontal wall]
-    '│': [stone, vertical wall]
-  upperleft: [-1, 1]
-  map: |
-    ┌───┐
-    │>TT│
-    └───┘
diff --git a/data/scenarios/Tutorials/bind2-solution.sw b/data/scenarios/Tutorials/bind2-solution.sw
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Tutorials/bind2-solution.sw
@@ -0,0 +1,11 @@
+build {
+    move; move;
+    turn right;
+    move; move; move; move; move; move;
+    x <- grab;
+    turn left;
+    move;
+    turn left;
+    move; move;
+    place x;
+}
diff --git a/data/scenarios/Tutorials/bind2.yaml b/data/scenarios/Tutorials/bind2.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Tutorials/bind2.yaml
@@ -0,0 +1,111 @@
+version: 1
+name: Bind notation
+creative: false
+description: |
+  Learn about command return types and how to bind the results.
+objectives:
+  - id: grab_artifact
+    teaser: Retrieve artifact
+    goal:
+      - |
+        A pedestal stands conspicuously empty in the center of the room.
+        Perhaps its intended contents lie nearby?
+      - |
+        Build a robot to retrieve and restore the mystery artifact to its proper place!
+      - |
+        Note: If you find yourself stuck, you can select "Start over" from
+        the "Quit" (CTRL+q) dialog.
+    condition: |
+      try {
+        p <- robotnamed "floorspot";
+        w <- as p {ishere "Hastur"};
+        return (not w);
+      } { return false }
+  - goal:
+      - |
+        Your robot obtained the misplaced artifact! Next you need to put it back
+        on the pedestal.  But neither you nor your robot knows the
+        artifact's name.  How can it be placed?
+      - |
+        Every command returns a value.  However, some simple commands, like
+        `move`, do not have any meaningful
+        value to return.  Swarm has a special type, `unit`, with only one value,
+        called `()`. Since there is only one possible value of type
+        `unit`, returning it does not convey any information.
+        Thus, the type of `move` is `cmd unit`.
+      - |
+        Other commands do return a nontrivial value after executing.
+        For example, `grab` has type `cmd text`, and returns the name of the
+        grabbed entity as a text value.
+      - |
+        To use the result of a command later, you need "bind notation", which
+        consists of a variable name and a leftwards-pointing arrow
+        before the command.  For example:
+      - |
+        move; t <- grab; place t
+      - |
+        In the above example, the result returned by `grab` is assigned
+        to the variable name `t`, which can then be used later.
+        This is useful, for example, if you do not care what you
+        grabbed and just want to move it to another cell, or if you
+        are not sure of the name of the thing being grabbed.
+      - |
+        Reminder: if you need to start over, your previous commands are
+        accessible in the REPL history (up-arrow).
+    condition: |
+      try {
+        p <- robotnamed "pedestal";
+        w <- as p {ishere "Hastur"};
+        return w;
+      } { return false }
+    prerequisite: grab_artifact
+solution: |
+  run "data/scenarios/Tutorials/bind2-solution.sw"
+entities:
+  - name: Hastur
+    display:
+      attr: gold
+      char: 'H'
+    description:
+      - The Unspeakable One
+    properties: [portable]
+robots:
+  - name: base
+    dir: [0,1]
+    display:
+      char: Ω
+      attr: robot
+    devices:
+      - 3D printer
+    inventory:
+      - [1, solar panel]
+      - [1, treads]
+      - [1, grabber]
+  - name: floorspot
+    system: true
+  - name: pedestal
+    system: true
+world:
+  default: [blank]
+  palette:
+    '.': [blank, null]
+    'Ω': [blank, null, base]
+    'x': [dirt, null]
+    'H': [blank, Hastur, floorspot]
+    's': [stone, null, pedestal]
+    '┌': [stone, upper left corner]
+    '┐': [stone, upper right corner]
+    '└': [stone, lower left corner]
+    '┘': [stone, lower right corner]
+    '─': [stone, horizontal wall]
+    '│': [stone, vertical wall]
+  upperleft: [-1, -3]
+  map: |
+    ┌─────────┐
+    │.........│
+    │...xxx...│
+    │...xsx...│
+    │...xxxH..│
+    │.........│
+    │Ω┌───────┘
+    └─┘........
diff --git a/data/scenarios/Tutorials/build.yaml b/data/scenarios/Tutorials/build.yaml
--- a/data/scenarios/Tutorials/build.yaml
+++ b/data/scenarios/Tutorials/build.yaml
@@ -14,11 +14,9 @@
       - You can build a robot with `build {COMMANDS}`,
         where in place of `COMMANDS` you write the sequence
         of commands for the robot to execute (separated by semicolons).
-      - Build a robot to harvest the flower and place it next
-        to the water.
       - |
-        TIP: You can use the name of the flower directly ("pickerelweed"),
-        or you can use bind notation: `f <- harvest; ... ; place f;`.
+        Build a robot to harvest the "flower" and place it next
+        to the water.
       - |
         TIP: Newly built robots start out facing the same
         direction as their parent, which in the tutorials will always be north.
@@ -26,26 +24,15 @@
       try {
         teleport self (0,-1);
         turn east;
-        a <- ishere "pickerelweed";
+        a <- ishere "flower";
         move;
-        b <- ishere "pickerelweed";
+        b <- ishere "flower";
         move;
-        c <- ishere "pickerelweed";
+        c <- ishere "flower";
         return (a || b || c);
       } { return false }
-entities:
-  - name: pickerelweed
-    display:
-      attr: flower
-      char: '*'
-    description:
-      - A small plant that grows near the water.
-    properties: [known, portable, growable]
-    # It would make sense for the plant to NOT grow away from water.
-    # But if the player sent a robot that grabbed it and then did not
-    # place it, another robot would need to salvage that robot.
 solution: |
-  build {turn right; move; move; t <- harvest; turn right; move; place t}
+  build {turn right; move; move; harvest; turn right; move; place "flower"}
 robots:
   - name: base
     dir: [0,1]
@@ -69,7 +56,7 @@
     'Ω': [grass, null, base]
     '.': [grass]
     '~': [ice, water]
-    '*': [grass, pickerelweed]
+    '*': [grass, flower]
     '┌': [stone, upper left corner]
     '┐': [stone, upper right corner]
     '└': [stone, lower left corner]
diff --git a/data/scenarios/Tutorials/conditionals.yaml b/data/scenarios/Tutorials/conditionals.yaml
--- a/data/scenarios/Tutorials/conditionals.yaml
+++ b/data/scenarios/Tutorials/conditionals.yaml
@@ -62,9 +62,6 @@
   def pick = move; ifC (ishere VSR) {grab; return ()} {} end;
   def pickrow = x4 pick; turn back; x4 move end;
   build {
-    require "treads"; require "branch predictor"; require "grabber";
-    require "lambda"; require "scanner";  // #540
-
     turn south; x4 (move; tL; pickrow; tL); tB; x4 move; x4 (give base VSR)
   }
 robots:
diff --git a/data/scenarios/Tutorials/craft.yaml b/data/scenarios/Tutorials/craft.yaml
--- a/data/scenarios/Tutorials/craft.yaml
+++ b/data/scenarios/Tutorials/craft.yaml
@@ -15,6 +15,11 @@
         available recipes.
       - Your goal is to make a "branch predictor", so you will have to make
         some "branch"es first.
+      - |
+        Note: when used after opening quotes in the REPL, the Tab key can cycle through
+        possible completions of a name. E.g., type:
+      - |
+        > make "br[Tab][Tab]
     condition: |
       try {
         as base {has "branch predictor"}
diff --git a/data/scenarios/Tutorials/crash-secret.sw b/data/scenarios/Tutorials/crash-secret.sw
--- a/data/scenarios/Tutorials/crash-secret.sw
+++ b/data/scenarios/Tutorials/crash-secret.sw
@@ -15,41 +15,39 @@
 end;
 
 // At the beginning all robots can be given Win.
-def allOK: robot -> bool = \rob.
+def allOK: actor -> bool = \rob.
   true
 end;
 
+myLoc <- whereami;
+
 // Try to give a robot a Win, filtering out those that were already given a Win.
 // The robot will also receive instructions, so it **must have a logger!**
-def tryGive: text -> (robot -> bool) -> int -> cmd (robot -> bool) = \msg.\f.\i.
-  r <- try {
-    robotNumbered i;
-  } {
-    log $ "could not find robot " ++ format i;
-    return self
-  };
-  if (r != self && f r) {
-    log $ "found the robot " ++ format i;
-    l <- whereami;
-    rl <- as r {whereami}; wait 1;  // WHY is this 'wait 1' required???
-    if (l != rl) {
-      log $ "the robot" ++ format i ++ "is not in my cell";
-      return f;
+def tryGive: text -> (actor -> bool) -> cmd (actor -> bool) = \msg.
+  // (b -> actor -> cmd b) -> b -> cmd b
+  meetAll $ \f.\rob.
+    if (not $ f rob) {
+      log $ "skipping the robot " ++ format rob ++ "because it already has a Win";
+      return f
     } {
-      try {
-        reprogram r { log msg; };
-        log $ "successfully reprogrammed robot " ++ format i;
-        give r "Win";
-        log $ "successfully gave Win to robot " ++ format i;
+      robLoc <- as rob {whereami};
+      if (robLoc != myLoc) {
+        log $ "the robot" ++ format rob ++ "is not in my cell";
+        return f;
       } {
-        log $ "the robot " ++ format i ++ "is missing a logger!"
-      };
-      return (\rob. (rob != r && f rob));
+        try {
+          reprogram rob { log msg; };
+          log $ "successfully reprogrammed robot " ++ format rob;
+          give rob "Win";
+          log $ "successfully gave Win to robot " ++ format rob;
+          return (\r. (rob != r && f rob));
+        } {
+          log $ "the robot " ++ format rob ++ "is missing a logger!";
+          return f;
+        };
+        
+      }
     }
-  } {
-    log $ "skipping the robot " ++ format i;
-    return f
-  }
 end;
 
 // -------------------------------------------------------------------------
@@ -57,7 +55,7 @@
 // -------------------------------------------------------------------------
 
 log "Hi, I am secret";
-iterate allOK (foreachF 1 16 $ tryGive
+iterate allOK (tryGive
   $ "Send a robot to `salvage` me and come back to `give base \"Win\"`.\n"
   ++ "When the rescue robot stands where I am and executes `salvage`,\n"
   ++ "all my inventory and logs will go to it, including the \"Win\".\n"
diff --git a/data/scenarios/Tutorials/def.yaml b/data/scenarios/Tutorials/def.yaml
--- a/data/scenarios/Tutorials/def.yaml
+++ b/data/scenarios/Tutorials/def.yaml
@@ -46,7 +46,6 @@
   def tB = turn back end;
   def S = m16; tL; m2; tL; m16; tR; m2; tR; m16 end;
   build {
-    require "treads";  // #540
     turn right; S; f <- harvest; tB; S; give base f
   }
 robots:
diff --git a/data/scenarios/Tutorials/equip.yaml b/data/scenarios/Tutorials/equip.yaml
new file mode 100644
--- /dev/null
+++ b/data/scenarios/Tutorials/equip.yaml
@@ -0,0 +1,61 @@
+version: 1
+name: Equip
+description: |
+  Learn how to equip devices and gain new capabilities.
+objectives:
+  - goal:
+      - You know how to `grab` things lying around, so you are ready to get
+        an upgrade!
+        By equipping devices, you learn new capabilities which allow you to
+        perform more complex commands.
+      - Before you start building new robots in the later tutorials, you need
+        to gain the "build" capability.
+        Try typing `build {}` - you should get an error telling you that you
+        need to equip a "3D printer".
+      - |
+        Fortunately, there is a 3D printer lying nearby.  Go `grab` it, then
+        equip it with `equip "3D printer"`.
+      - |
+        You win by building your first robot:
+      - |
+        build {}
+    condition: |
+      try {
+        _ <- robotNumbered 1;
+        return true;
+      } { return false }
+solution: |
+  turn south; move; grab; equip "3D printer"; build {};
+robots:
+  - name: base
+    dir: [1,0]
+    devices:
+      - logger
+      - treads
+      - compass
+      - grabber
+      - welder
+    inventory:
+      - [10, solar panel]
+      - [10, logger]
+known: [3D printer, water]
+world:
+  default: [blank]
+  palette:
+    '>': [grass, null, base]
+    '.': [grass]
+    '~': [ice, water]
+    '3': [grass, 3D printer]
+    '┌': [stone, upper left corner]
+    '┐': [stone, upper right corner]
+    '└': [stone, lower left corner]
+    '┘': [stone, lower right corner]
+    '─': [stone, horizontal wall]
+    '│': [stone, vertical wall]
+  upperleft: [-1, 1]
+  map: |
+    ┌───┐
+    │>..│
+    │3..│
+    │~~~│
+    └───┘
diff --git a/data/scenarios/Tutorials/farming.sw b/data/scenarios/Tutorials/farming.sw
--- a/data/scenarios/Tutorials/farming.sw
+++ b/data/scenarios/Tutorials/farming.sw
@@ -11,7 +11,7 @@
 def while : cmd bool -> {cmd a} -> cmd unit = \test. \body.
   ifC test {force body ; while test body} {}
 end;
-def giveall : robot -> text -> cmd unit = \r. \thing.
+def giveall : actor -> text -> cmd unit = \r. \thing.
   while (has thing) {give r thing}
 end;
 def x4 = \c. c; c; c; c end;
@@ -20,7 +20,6 @@
 def m12 = x12 move end;
 def next_row = tB; m12; tL; move; tL end;
 def plant_field : text -> cmd unit = \thing.
-  log "planting";
   x4 (
     x12 (move; place thing; harvest);
     next_row
@@ -39,14 +38,9 @@
   )
 end;
 build {
-  require "treads"; require "harvester"; require "logger";
-  require "lambda"; require "branch predictor";             // #540
   require 1 "lambda";
   tB; move; tR; plant_field "lambda";
 };
 build {
-  require "treads"; require "harvester"; require "logger"; require "scanner";
-  require "grabber";
-  require "lambda"; require "branch predictor"; require "strange loop";  // #540
   harvest_lambdas
 }
diff --git a/data/scenarios/Tutorials/farming.yaml b/data/scenarios/Tutorials/farming.yaml
--- a/data/scenarios/Tutorials/farming.yaml
+++ b/data/scenarios/Tutorials/farming.yaml
@@ -3,7 +3,9 @@
 description: |
   Learn how to build a farm to grow and harvest items.
 objectives:
-  - goal:
+  - id: get_many_lambdas
+    teaser: Get 256 lambdas
+    goal:
       - Lambdas are an essential item for building robots, but they
         are somewhat rare in the wild.  Therefore, it makes sense to farm
         them in order to create a reliable supply.
@@ -47,6 +49,7 @@
       - Now go forth and build your swarm!
     condition: |
       try {as base {has "curry"}} {return false}
+    prerequisite: get_many_lambdas
 solution: |
   run "scenarios/Tutorials/farming.sw";
   run "scenarios/Tutorials/make_curry.sw";
@@ -61,6 +64,7 @@
       - 3D printer
       - dictionary
       - grabber
+      - welder
       - life support system
       - logger
       - toolkit
diff --git a/data/scenarios/Tutorials/install.yaml b/data/scenarios/Tutorials/install.yaml
deleted file mode 100644
--- a/data/scenarios/Tutorials/install.yaml
+++ /dev/null
@@ -1,60 +0,0 @@
-version: 1
-name: Install
-description: |
-  Learn how to install devices and gain new capabilities.
-objectives:
-  - goal:
-      - You know how to `grab` things lying around, so you are ready to get
-        an upgrade!
-        By installing devices, you learn new capabilities which allow you to
-        perform more complex commands.
-      - Before you start building new robots in the later tutorials, you need
-        to gain the "build" capability.
-        Try typing `build {}` - you should get an error telling you that you
-        need to install a "3D printer".
-      - |
-        Fortunately, there is a 3D printer lying nearby.  Go `grab` it, then
-        install it on yourself with `install base "3D printer"`.
-      - |
-        You win by building your first robot:
-      - |
-        build {log "Hello World!"}
-    condition: |
-      try {
-        _ <- robotNumbered 1;
-        return true;
-      } { return false }
-solution: |
-  turn south; move; grab; install base "3D printer"; build {log "Hello World!"};
-robots:
-  - name: base
-    dir: [1,0]
-    devices:
-      - logger
-      - treads
-      - compass
-      - grabber
-    inventory:
-      - [10, solar panel]
-      - [10, logger]
-known: [3D printer, water]
-world:
-  default: [blank]
-  palette:
-    '>': [grass, null, base]
-    '.': [grass]
-    '~': [ice, water]
-    '3': [grass, 3D printer]
-    '┌': [stone, upper left corner]
-    '┐': [stone, upper right corner]
-    '└': [stone, lower left corner]
-    '┘': [stone, lower right corner]
-    '─': [stone, horizontal wall]
-    '│': [stone, vertical wall]
-  upperleft: [-1, 1]
-  map: |
-    ┌───┐
-    │>..│
-    │3..│
-    │~~~│
-    └───┘
diff --git a/data/scenarios/Tutorials/lambda.yaml b/data/scenarios/Tutorials/lambda.yaml
--- a/data/scenarios/Tutorials/lambda.yaml
+++ b/data/scenarios/Tutorials/lambda.yaml
@@ -34,7 +34,6 @@
   def tL = turn left end;
   def s = m4; tL; m2; tL; m4; tR; m2; tR end;
   build {
-    require "treads"; require "lambda";  // #540
     turn right;
     x4 (x4 s; m4; m2; tR; x4 (x4 move); tL; m2);
     grab
diff --git a/data/scenarios/Tutorials/move.yaml b/data/scenarios/Tutorials/move.yaml
--- a/data/scenarios/Tutorials/move.yaml
+++ b/data/scenarios/Tutorials/move.yaml
@@ -3,7 +3,9 @@
 description: |
   Learn how to move and chain commands.
 objectives:
-  - goal:
+  - id: move_to_first_flower
+    teaser: Get the flower
+    goal:
       - Robots can use the `move` command to move forward one unit
         in the direction they are currently facing.
       - To complete this challenge, move your robot two spaces to the right,
@@ -14,7 +16,9 @@
     condition: |
       r <- robotNamed "check1";
       loc <- as r {has "Win"};
-  - goal:
+  - id: move_along_corridor
+    teaser: Down the corridor
+    goal:
       - Good! Now you need to learn how to effectively repeat actions.
       - |
         Previously you could move twice by chaining the move command:
@@ -29,7 +33,10 @@
     condition: |
       r <- robotNamed "check2";
       loc <- as r {has "Win"};
-  - goal:
+    prerequisite: move_to_first_flower
+  - id: move_northeast_corner
+    teaser: To northeast corner
+    goal:
       - Well done! In addition to `move`, you can use the `turn` command
         to turn your robot, for example, `turn right` or `turn east`.
       - Switch to the inventory view in the upper left (by clicking on it or typing `Alt+E`)
@@ -51,6 +58,7 @@
     condition: |
       r <- robotNamed "check3";
       loc <- as r {has "Win"};
+    prerequisite: move_along_corridor
   - goal:
       - Good job! You are now ready to move and turn on your own.
       - To complete this challenge, move your robot to the northeast corner,
@@ -61,6 +69,7 @@
     condition: |
       r <- robotNamed "check4";
       loc <- as r {has "Win"};
+    prerequisite: move_northeast_corner
 solution: |
   // 0
   move;move;
diff --git a/data/scenarios/Tutorials/require.yaml b/data/scenarios/Tutorials/require.yaml
--- a/data/scenarios/Tutorials/require.yaml
+++ b/data/scenarios/Tutorials/require.yaml
@@ -1,24 +1,25 @@
 version: 1
 name: Require devices
 description: |
-  Learn how to require additional devices that would otherwise not be installed.
+  Learn how to require additional devices that would otherwise not be equipped.
 objectives:
   - goal:
-      - The `build` command automatically installs devices that it knows
+      - The `build` command automatically equips devices on the newly
+        built robot that it knows
         will be required.  For example, if you `build {move}`, some `treads`
-        will automatically be installed on the new robot since it needs
+        will automatically be equipped on the new robot since it needs
         them to `move`.
       - However, sometimes you need a device but `build` can't tell that
         you need it. In this case, you can use the special `require`
         command to require a particular device.  For example, if you
         `build {require "3D printer"; move}`, a 3D printer will be
-        installed on the new robot even though it does not execute any
+        equipped on the new robot even though it does not execute any
         commands that use a 3D printer.
       - Your goal is to pick a flower on the other side of the river and
         bring it back to your base.  You win when the base has a
         `periwinkle` flower in its inventory.
       - "Hint: robots will drown in the water unless they have a `boat` device
-        installed!"
+        equipped!"
     condition: |
       try {
         as base {has "periwinkle"}
@@ -34,8 +35,6 @@
 solution: |
   def m5 = move; move; move; move; move end;
   build {
-    require "treads";  // #540
-
     require "boat"; turn right;
     m5; f <- grab; turn back; m5; give base f
   }
diff --git a/data/scenarios/Tutorials/requireinv.yaml b/data/scenarios/Tutorials/requireinv.yaml
--- a/data/scenarios/Tutorials/requireinv.yaml
+++ b/data/scenarios/Tutorials/requireinv.yaml
@@ -5,8 +5,8 @@
 objectives:
   - goal:
       - In the previous tutorial challenge, you learned how to use
-        `require` to require specific devices to be installed.
-        Sometimes, instead of requiring installed devices, you require
+        `require` to require specific devices to be equipped.
+        Sometimes, instead of requiring equipped devices, you require
         supplies in your inventory.  In this case, you can write
         `require <int> <name>` to require a certain number of copies of
         a certain entity to be placed in your inventory.
@@ -38,7 +38,6 @@
   def mp = move; place "rock" end;
   def rr = turn right; move; turn right; x4 mp; turn back; x4 move end;
   build {
-    require "treads"; require "grabber"; require "lambda"; // #540
     require 16 "rock";
     x4 rr
   };
diff --git a/data/scenarios/Tutorials/world101.yaml b/data/scenarios/Tutorials/world101.yaml
--- a/data/scenarios/Tutorials/world101.yaml
+++ b/data/scenarios/Tutorials/world101.yaml
@@ -3,14 +3,16 @@
 description: |
   Learn some of the first steps you might take on the new planet.
 objectives:
-  - goal:
+  - id: get_trees
+    teaser: Get 3 trees
+    goal:
       - You're ready to graduate to something a bit more complex! This
         multi-step tutorial will walk you through a few of the first
         steps you might take in exploring the new planet, using many
         of the commands and techniques you have learned in the
         previous tutorial challenges.
       - You can see that your base starts out with some key devices
-        installed and some basic supplies for building robots.  To
+        equipped and some basic supplies for building robots.  To
         build more advanced devices and produce more robots,
         you'll need to explore, gather resources, and set up some
         automated production pipelines.
@@ -26,7 +28,9 @@
         n <- as base {count "tree"};
         return (n >= 3)
       } { return false }
-  - goal:
+  - id: get_harvester
+    teaser: Make a harvester
+    goal:
       - Nice work!  Now, use the trees to make a harvester device.
         This will require several intermediate products; try making
         various things, and take a look at your available recipes (F3)
@@ -34,6 +38,7 @@
         you may end up needing some additional trees.
     condition: |
       try { as base {has "harvester"} } {return false}
+    prerequisite: get_trees
   - goal:
       - Now that you have a harvester, you can use `harvest` instead of `grab`
         whenever you pick up a growing item (check for the word "growing" at the
@@ -50,6 +55,7 @@
         coordinates."
     condition: |
       try { as base {has "lambda"} } {return false}
+    prerequisite: get_harvester
 robots:
   - name: base
     display:
@@ -61,6 +67,7 @@
       - 3D printer
       - dictionary
       - grabber
+      - welder
       - life support system
       - logger
       - toolkit
diff --git a/data/scenarios/classic.yaml b/data/scenarios/classic.yaml
--- a/data/scenarios/classic.yaml
+++ b/data/scenarios/classic.yaml
@@ -13,6 +13,7 @@
       - 3D printer
       - dictionary
       - grabber
+      - welder
       - life support system
       - logger
       - toolkit
diff --git a/data/test/language-snippets/warnings/unused-vars/multiple-lambda-first-unused.sw b/data/test/language-snippets/warnings/unused-vars/multiple-lambda-first-unused.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/multiple-lambda-first-unused.sw
@@ -0,0 +1,3 @@
+def put = \x. \y.
+    place y;
+    end;
diff --git a/data/test/language-snippets/warnings/unused-vars/multiple-lambda-second-unused.sw b/data/test/language-snippets/warnings/unused-vars/multiple-lambda-second-unused.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/multiple-lambda-second-unused.sw
@@ -0,0 +1,3 @@
+def put = \x. \y.
+    place x;
+    end;
diff --git a/data/test/language-snippets/warnings/unused-vars/multiple-let-all-unused.sw b/data/test/language-snippets/warnings/unused-vars/multiple-let-all-unused.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/multiple-let-all-unused.sw
@@ -0,0 +1,3 @@
+let x = "foo" in
+let y = "bar" in
+place "quux";
diff --git a/data/test/language-snippets/warnings/unused-vars/multiple-let-first-unused.sw b/data/test/language-snippets/warnings/unused-vars/multiple-let-first-unused.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/multiple-let-first-unused.sw
@@ -0,0 +1,3 @@
+let x = "foo" in
+let y = "bar" in
+place y;
diff --git a/data/test/language-snippets/warnings/unused-vars/multiple-let-second-unused.sw b/data/test/language-snippets/warnings/unused-vars/multiple-let-second-unused.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/multiple-let-second-unused.sw
@@ -0,0 +1,3 @@
+let x = "foo" in
+let y = "bar" in
+place x;
diff --git a/data/test/language-snippets/warnings/unused-vars/recursive-let.sw b/data/test/language-snippets/warnings/unused-vars/recursive-let.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/recursive-let.sw
@@ -0,0 +1,1 @@
+let one = 1 in let fac = \n. if (n == 0) {one} {n * fac (n-1)} in 17
diff --git a/data/test/language-snippets/warnings/unused-vars/shadowed-variable-lambda-unused.sw b/data/test/language-snippets/warnings/unused-vars/shadowed-variable-lambda-unused.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/shadowed-variable-lambda-unused.sw
@@ -0,0 +1,1 @@
+def y = \x. \x. x + 1 end
diff --git a/data/test/language-snippets/warnings/unused-vars/shadowed-variable-let-intermediate-use.sw b/data/test/language-snippets/warnings/unused-vars/shadowed-variable-let-intermediate-use.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/shadowed-variable-let-intermediate-use.sw
@@ -0,0 +1,4 @@
+let x = "flower" in
+place x;
+x <- grab;
+give parent x;
diff --git a/data/test/language-snippets/warnings/unused-vars/shadowed-variable-let-unused.sw b/data/test/language-snippets/warnings/unused-vars/shadowed-variable-let-unused.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/shadowed-variable-let-unused.sw
@@ -0,0 +1,3 @@
+let x = "flower" in
+x <- grab;
+give parent x;
diff --git a/data/test/language-snippets/warnings/unused-vars/single-bind-unused.sw b/data/test/language-snippets/warnings/unused-vars/single-bind-unused.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/single-bind-unused.sw
@@ -0,0 +1,2 @@
+x <- grab;
+place "foo";
diff --git a/data/test/language-snippets/warnings/unused-vars/single-bind-used.sw b/data/test/language-snippets/warnings/unused-vars/single-bind-used.sw
new file mode 100644
--- /dev/null
+++ b/data/test/language-snippets/warnings/unused-vars/single-bind-used.sw
@@ -0,0 +1,2 @@
+x <- grab;
+place x;
diff --git a/editors/emacs/swarm-mode.el b/editors/emacs/swarm-mode.el
--- a/editors/emacs/swarm-mode.el
+++ b/editors/emacs/swarm-mode.el
@@ -45,6 +45,8 @@
                "format"
                "chars"
                "split"
+               "charat"
+               "tochar"
              ))
              (x-commands '(
                "noop"
@@ -56,10 +58,11 @@
                "harvest"
                "place"
                "give"
-               "install"
+               "equip"
+               "unequip"
                "make"
                "has"
-               "installed"
+               "equipped"
                "count"
                "drill"
                "build"
@@ -73,10 +76,14 @@
                "create"
                "time"
                "whereami"
+               "heading"
                "blocked"
                "scan"
                "upload"
                "ishere"
+               "isempty"
+               "meet"
+               "meetall"
                "whoami"
                "setname"
                "random"
@@ -90,17 +97,17 @@
                "robotnamed"
                "robotnumbered"
                "knows"
-               "left"
-               "right"
-               "back"
-               "forward"
                "north"
                "south"
                "east"
                "west"
+               "left"
+               "right"
+               "back"
+               "forward"
                "down"
              ))
-             (x-types '("int" "text" "dir" "bool" "cmd" "void" "unit"))
+             (x-types '("int" "text" "dir" "bool" "cmd" "void" "unit" "actor"))
 
              (x-keywords-regexp (regexp-opt x-keywords 'words))
              (x-builtins-regexp (regexp-opt x-builtins 'words))
diff --git a/editors/vscode/syntaxes/swarm.tmLanguage.json b/editors/vscode/syntaxes/swarm.tmLanguage.json
--- a/editors/vscode/syntaxes/swarm.tmLanguage.json
+++ b/editors/vscode/syntaxes/swarm.tmLanguage.json
@@ -29,7 +29,7 @@
 				},
 				{
 				"name": "keyword.control.dictionary.let",
-				"begin": "\\s*let\\s+(\\w+)\\s*(:((\\s*(cmd|dir|text|int|void|unit|\\(|\\)|\\{|\\}|(\\*|\\+|->)|[a-z]\\w*|forall ([a-z]\\w*\\s*)+.)\\s*)+))?=",
+				"begin": "\\s*let\\s+(\\w+)\\s*(:((\\s*(cmd|dir|text|int|void|unit|actor|\\(|\\)|\\{|\\}|(\\*|\\+|->)|[a-z]\\w*|forall ([a-z]\\w*\\s*)+.)\\s*)+))?=",
 				"end": "\\s*in",
 				"beginCaptures": {
 					"1": {"name": "entity.name.function"},
@@ -58,7 +58,7 @@
 				},
 				{
 				"name": "keyword.other",
-				"match": "\\b(?i)(self|parent|base|if|inl|inr|case|fst|snd|force|undefined|fail|not|format|chars|split|noop|wait|selfdestruct|move|turn|grab|harvest|place|give|install|make|has|installed|count|drill|build|salvage|reprogram|say|listen|log|view|appear|create|time|whereami|blocked|scan|upload|ishere|whoami|setname|random|run|return|try|swap|atomic|teleport|as|robotnamed|robotnumbered|knows)\\b"
+				"match": "\\b(?i)(self|parent|base|if|inl|inr|case|fst|snd|force|undefined|fail|not|format|chars|split|charat|tochar|noop|wait|selfdestruct|move|turn|grab|harvest|place|give|equip|unequip|make|has|equipped|count|drill|build|salvage|reprogram|say|listen|log|view|appear|create|time|whereami|heading|blocked|scan|upload|ishere|isempty|meet|meetall|whoami|setname|random|run|return|try|swap|atomic|teleport|as|robotnamed|robotnumbered|knows)\\b"
 				}
 			]
 			},
@@ -89,7 +89,7 @@
 			"patterns": [
 				{
 				"name": "variable.language.dir",
-				"match": "\\b(?i)(left|right|back|forward|north|south|east|west|down)\\b"
+				"match": "\\b(?i)(north|south|east|west|left|right|back|forward|down)\\b"
 				},
 				{
 				"name": "variable.parameter",
diff --git a/example/BFS-clear.sw b/example/BFS-clear.sw
--- a/example/BFS-clear.sw
+++ b/example/BFS-clear.sw
@@ -63,4 +63,4 @@
   give base "tree";
   selfdestruct;
 end;
-def start : cmd robot = build {turn west; repeat 7 move; clear} end
+def start : cmd actor = build {turn west; repeat 7 move; clear} end
diff --git a/example/list.sw b/example/list.sw
--- a/example/list.sw
+++ b/example/list.sw
@@ -148,7 +148,7 @@
 // nil : listI
 def nil = 0 end
 
-// Add non-negative number to beggining of list (cons adds the sign)
+// Add non-negative number to beginning of list (cons adds the sign)
 // consP : nat -> listI -> int
 def consP = \x.\xs.
   if (x == 0)
@@ -159,7 +159,7 @@
     }
 end
 
-// Add integer to the beggining of the list
+// Add integer to the beginning of the list
 // consP : int -> listI -> listI
 def cons = \x.\xs.
   if (x >= 0)
diff --git a/src/Data/BoolExpr.hs b/src/Data/BoolExpr.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/BoolExpr.hs
@@ -0,0 +1,337 @@
+{-
+Copyright (c) 2008, 2009, Nicolas Pouillard
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of the copyright holders nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+-}
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE GeneralizedNewtypeDeriving #-}
+{-# LANGUAGE TypeFamilies #-}
+{-# LANGUAGE NoDeriveAnyClass #-}
+
+--------------------------------------------------------------------
+
+--------------------------------------------------------------------
+
+-- |
+-- Module    : Data.BoolExpr
+-- Copyright : (c) Nicolas Pouillard 2008,2009
+-- License   : BSD3
+--
+-- Maintainer: Nicolas Pouillard <nicolas.pouillard@gmail.com>
+-- Stability : provisional
+-- Portability:
+--
+-- Boolean expressions and various representations.
+module Data.BoolExpr (
+  -- * A boolean class
+  Boolean (..),
+
+  -- * Boolean trees
+  BoolExpr (..),
+  reduceBoolExpr,
+  evalBoolExpr,
+
+  -- * Boolean evaluation semantic
+  Eval (..),
+  runEvalId,
+
+  -- * Signed constants
+  Signed (..),
+  negateSigned,
+  evalSigned,
+  constants,
+  negateConstant,
+
+  -- * Conjunctive Normal Form
+  CNF (..),
+  Conj (..),
+  boolTreeToCNF,
+
+  -- * Disjunctive Normal Form
+  Disj (..),
+  DNF (..),
+  boolTreeToDNF,
+
+  -- * Other transformations
+  dualize,
+  fromBoolExpr,
+  pushNotInwards,
+)
+where
+
+import Control.Monad (ap)
+import Data.Aeson
+import Data.Char (toLower)
+import Data.Traversable
+import GHC.Generics (Generic)
+
+-- | Signed values are either positive or negative.
+data Signed a = Positive a | Negative a
+  deriving (Eq, Ord, Generic, Show, Read)
+
+instance Functor Signed where
+  fmap f (Positive x) = Positive (f x)
+  fmap f (Negative x) = Negative (f x)
+
+instance Traversable Signed where
+  traverse f (Positive x) = Positive <$> f x
+  traverse f (Negative x) = Negative <$> f x
+
+instance Foldable Signed where
+  foldMap = foldMapDefault
+
+instance Applicative Signed where
+  pure = Positive
+  (<*>) = ap
+
+instance Monad Signed where
+  Positive x >>= f = f x
+  Negative x >>= f = negateSigned $ f x
+
+infix 9 /\
+infix 9 \/
+
+-- | A boolean type class.
+class Boolean f where
+  (/\) :: f a -> f a -> f a
+  (\/) :: f a -> f a -> f a
+  bNot :: f a -> f a
+  bTrue :: f a
+  bFalse :: f a
+  bConst :: Signed a -> f a
+
+-- | Syntax of boolean expressions parameterized over a
+-- set of leaves, named constants.
+data BoolExpr a
+  = BAnd (BoolExpr a) (BoolExpr a)
+  | BOr (BoolExpr a) (BoolExpr a)
+  | BNot (BoolExpr a)
+  | BTrue
+  | BFalse
+  | BConst (Signed a)
+  deriving (Eq, Ord, Generic, Show {-! derive : Arbitrary !-})
+
+encodingOptions :: Options
+encodingOptions =
+  defaultOptions
+    { sumEncoding = ObjectWithSingleField
+    , constructorTagModifier = map toLower
+    }
+
+instance (ToJSON a) => ToJSON (Signed a) where
+  toJSON = genericToJSON encodingOptions
+
+instance (ToJSON a) => ToJSON (BoolExpr a) where
+  toJSON = genericToJSON encodingOptions
+
+instance (ToJSON a) => ToJSON (DNF a) where
+  toJSON = genericToJSON encodingOptions
+
+instance (ToJSON a) => ToJSON (CNF a) where
+  toJSON = genericToJSON encodingOptions
+
+instance (ToJSON a) => ToJSON (Conj a) where
+  toJSON = genericToJSON encodingOptions
+
+instance (ToJSON a) => ToJSON (Disj a) where
+  toJSON = genericToJSON encodingOptions
+
+instance Functor BoolExpr where
+  fmap f (BAnd a b) = BAnd (fmap f a) (fmap f b)
+  fmap f (BOr a b) = BOr (fmap f a) (fmap f b)
+  fmap f (BNot t) = BNot (fmap f t)
+  fmap _ BTrue = BTrue
+  fmap _ BFalse = BFalse
+  fmap f (BConst x) = BConst (fmap f x)
+
+instance Traversable BoolExpr where
+  traverse f (BAnd a b) = BAnd <$> traverse f a <*> traverse f b
+  traverse f (BOr a b) = BOr <$> traverse f a <*> traverse f b
+  traverse f (BNot t) = BNot <$> traverse f t
+  traverse _ BTrue = pure BTrue
+  traverse _ BFalse = pure BFalse
+  traverse f (BConst x) = BConst <$> traverse f x
+
+instance Foldable BoolExpr where
+  foldMap = foldMapDefault
+
+instance Boolean BoolExpr where
+  (/\) = BAnd
+  (\/) = BOr
+  bNot = BNot
+  bTrue = BTrue
+  bFalse = BFalse
+  bConst = BConst
+
+newtype Eval b a = Eval {runEval :: (a -> b) -> b}
+
+runEvalId :: Eval a a -> a
+runEvalId e = runEval e id
+
+instance b ~ Bool => Boolean (Eval b) where
+  (/\) = liftE2 (&&)
+  (\/) = liftE2 (||)
+  bNot = liftE not
+  bTrue = Eval $ const True
+  bFalse = Eval $ const False
+  bConst = Eval . flip evalSigned
+
+liftE :: (b -> b) -> Eval b a -> Eval b a
+liftE f (Eval x) = Eval (f . x)
+
+liftE2 :: (b -> b -> b) -> Eval b a -> Eval b a -> Eval b a
+liftE2 f (Eval x) (Eval y) = Eval (\e -> f (x e) (y e))
+
+-- | Turns a boolean tree into any boolean type.
+fromBoolExpr :: Boolean f => BoolExpr a -> f a
+fromBoolExpr (BAnd l r) = fromBoolExpr l /\ fromBoolExpr r
+fromBoolExpr (BOr l r) = fromBoolExpr l \/ fromBoolExpr r
+fromBoolExpr (BNot t) = bNot $ fromBoolExpr t
+fromBoolExpr BTrue = bTrue
+fromBoolExpr BFalse = bFalse
+fromBoolExpr (BConst c) = bConst c
+
+--- | Disjunction of atoms ('a')
+newtype Disj a = Disj {unDisj :: [a]}
+  deriving (Show, Generic, Functor, Semigroup, Monoid)
+
+--- | Conjunction of atoms ('a')
+newtype Conj a = Conj {unConj :: [a]}
+  deriving (Show, Generic, Functor, Semigroup, Monoid)
+
+--- | Conjunctive Normal Form
+newtype CNF a = CNF {unCNF :: Conj (Disj (Signed a))}
+  deriving (Show, Generic, Semigroup, Monoid)
+
+--- | Disjunctive Normal Form
+newtype DNF a = DNF {unDNF :: Disj (Conj (Signed a))}
+  deriving (Show, Generic, Semigroup, Monoid)
+
+instance Functor CNF where
+  fmap f (CNF x) = CNF (fmap (fmap (fmap f)) x)
+
+instance Boolean CNF where
+  l /\ r = l `mappend` r
+  l \/ r =
+    CNF $
+      Conj
+        [ x `mappend` y | x <- unConj $ unCNF l, y <- unConj $ unCNF r
+        ]
+  bNot = error "bNot on CNF"
+  bTrue = CNF $ Conj []
+  bFalse = CNF $ Conj [Disj []]
+  bConst x = CNF $ Conj [Disj [x]]
+
+instance Functor DNF where
+  fmap f (DNF x) = DNF (fmap (fmap (fmap f)) x)
+
+instance Boolean DNF where
+  l /\ r =
+    DNF $
+      Disj
+        [ x `mappend` y | x <- unDisj $ unDNF l, y <- unDisj $ unDNF r
+        ]
+  l \/ r = l `mappend` r
+  bNot = error "bNot on CNF"
+  bTrue = DNF $ Disj [Conj []]
+  bFalse = DNF $ Disj []
+  bConst x = DNF $ Disj [Conj [x]]
+
+-- | Reduce a boolean tree annotated by booleans to a single boolean.
+reduceBoolExpr :: BoolExpr Bool -> Bool
+reduceBoolExpr = evalBoolExpr id
+
+-- Given a evaluation function of constants, returns an evaluation
+-- function over boolean trees.
+--
+-- Note that since 'BoolExpr' is a functor, one can simply use
+-- 'reduceBoolExpr':
+--
+-- @
+-- evalBoolExpr f = reduceBoolExpr . fmap (f$)
+-- @
+evalBoolExpr :: (a -> Bool) -> (BoolExpr a -> Bool)
+evalBoolExpr env expr = runEval (fromBoolExpr expr) env
+
+-- | Returns constants used in a given boolean tree, these
+-- constants are returned signed depending one how many
+-- negations stands over a given constant.
+constants :: BoolExpr a -> [Signed a]
+constants = go True
+ where
+  go sign (BAnd a b) = go sign a ++ go sign b
+  go sign (BOr a b) = go sign a ++ go sign b
+  go sign (BNot t) = go (not sign) t
+  go _ BTrue = []
+  go _ BFalse = []
+  go sign (BConst x) = [if sign then x else negateSigned x]
+
+dualize :: Boolean f => BoolExpr a -> f a
+dualize (BAnd l r) = dualize l \/ dualize r
+dualize (BOr l r) = dualize l /\ dualize r
+dualize BTrue = bFalse
+dualize BFalse = bTrue
+dualize (BConst c) = negateConstant c
+dualize (BNot e) = fromBoolExpr e
+
+-- When dualize is used by pushNotInwards not BNot remain,
+-- hence it makes sense to assert that dualize does not
+-- have to work on BNot. However `dualize` can be freely
+-- used as a fancy `bNot`.
+-- dualize (BNot _)   = error "dualize: impossible"
+
+-- | Push the negations inwards as much as possible.
+-- The resulting boolean tree no longer use negations.
+pushNotInwards :: Boolean f => BoolExpr a -> f a
+pushNotInwards (BAnd l r) = pushNotInwards l /\ pushNotInwards r
+pushNotInwards (BOr l r) = pushNotInwards l \/ pushNotInwards r
+pushNotInwards (BNot t) = dualize $ pushNotInwards t
+pushNotInwards BTrue = bTrue
+pushNotInwards BFalse = bFalse
+pushNotInwards (BConst c) = bConst c
+
+-- | Convert a boolean tree to a conjunctive normal form.
+boolTreeToCNF :: BoolExpr a -> CNF a
+boolTreeToCNF = pushNotInwards
+
+-- | Convert a boolean tree to a disjunctive normal form.
+boolTreeToDNF :: BoolExpr a -> DNF a
+boolTreeToDNF = pushNotInwards
+
+evalSigned :: (a -> Bool) -> Signed a -> Bool
+evalSigned f (Positive x) = f x
+evalSigned f (Negative x) = not $ f x
+
+negateSigned :: Signed a -> Signed a
+negateSigned (Positive x) = Negative x
+negateSigned (Negative x) = Positive x
+
+negateConstant :: Boolean f => Signed a -> f a
+negateConstant = bConst . negateSigned
diff --git a/src/Data/BoolExpr/Simplify.hs b/src/Data/BoolExpr/Simplify.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/BoolExpr/Simplify.hs
@@ -0,0 +1,57 @@
+-- | Simplification logic for boolean expressions that is not
+-- provided in the 'boolexpr' package.
+module Data.BoolExpr.Simplify (
+  cannotBeTrue,
+  replace,
+) where
+
+import Data.BoolExpr
+import Data.List qualified as L
+import Data.Map (Map)
+import Data.Map qualified as M
+import Data.Set qualified as S
+
+-- | Used only by "hasContradiction".
+-- Note that the Booleans returned in this tuple are not actually used
+-- as conditions, and therefore their semantic convention (e.g. associating
+-- True = Positive and False = Negative) is irrelevant.
+-- Rather, they are collected into sets
+-- to determine whether both True and False exist for a key.
+extractConstFromSigned :: Signed a -> (a, Bool)
+extractConstFromSigned v = case v of
+  Negative x -> (x, False)
+  Positive x -> (x, True)
+
+hasContradiction :: Ord a => Conj (Signed a) -> Bool
+hasContradiction (Conj items) =
+  not $
+    M.null $
+      M.filter ((> 1) . S.size) $
+        M.fromListWith (<>) $
+          fmap (fmap S.singleton . extractConstFromSigned) items
+
+simplifyDNF :: Ord a => DNF a -> DNF a
+simplifyDNF (DNF (Disj disjunctions)) =
+  DNF $ Disj $ L.filter (not . hasContradiction) disjunctions
+
+isAlwaysFalse :: Ord a => DNF a -> Bool
+isAlwaysFalse (DNF (Disj disjunctions)) = L.null disjunctions
+
+cannotBeTrue :: Ord a => BoolExpr a -> Bool
+cannotBeTrue = isAlwaysFalse . simplifyDNF . boolTreeToDNF
+
+replace :: Ord a => Map a Bool -> BoolExpr a -> BoolExpr a
+replace f (BAnd a b) = BAnd (replace f a) (replace f b)
+replace f (BOr a b) = BOr (replace f a) (replace f b)
+replace f (BNot t) = BNot (replace f t)
+replace _ BTrue = BTrue
+replace _ BFalse = BFalse
+replace m c@(BConst x) = case M.lookup varname m of
+  Nothing -> c
+  Just val ->
+    if txform val
+      then BTrue
+      else BFalse
+ where
+  (varname, isPositive) = extractConstFromSigned x
+  txform = if isPositive then id else not
diff --git a/src/Swarm/App.hs b/src/Swarm/App.hs
--- a/src/Swarm/App.hs
+++ b/src/Swarm/App.hs
@@ -13,16 +13,17 @@
 import Brick
 import Brick.BChan
 import Control.Concurrent (forkIO, threadDelay)
-import Control.Lens ((%~), (&), (?~), (^.))
+import Control.Lens (view, (%~), (&), (?~))
 import Control.Monad.Except
 import Data.IORef (newIORef, writeIORef)
 import Data.Text qualified as T
 import Data.Text.IO qualified as T
 import Graphics.Vty qualified as V
-import Swarm.Game.Robot (LogSource (ErrorTrace, Said))
-import Swarm.TUI.Attr
+import Swarm.Game.Robot (ErrorLevel (..), LogSource (ErrorTrace, Said))
 import Swarm.TUI.Controller
 import Swarm.TUI.Model
+import Swarm.TUI.Model.StateUpdate
+import Swarm.TUI.Model.UI (uiAttrMap)
 import Swarm.TUI.View
 import Swarm.Version (getNewerReleaseVersion)
 import Swarm.Web
@@ -38,7 +39,7 @@
     , appChooseCursor = chooseCursor
     , appHandleEvent = eventHandler
     , appStartEvent = enablePasteMode
-    , appAttrMap = const swarmAttrMap
+    , appAttrMap = view $ uiState . uiAttrMap
     }
 
 -- | The main @IO@ computation which initializes the state, sets up
@@ -74,27 +75,30 @@
         writeBChan chan (UpstreamVersion upRel)
 
       -- Start the web service with a reference to the game state
-      gsRef <- newIORef (s ^. gameState)
-      eport <- Swarm.Web.startWebThread (userWebPort opts) gsRef
+      appStateRef <- newIORef s
+      eport <- Swarm.Web.startWebThread (userWebPort opts) appStateRef
 
       let logP p = logEvent Said ("Web API", -2) ("started on :" <> T.pack (show p))
-      let logE e = logEvent ErrorTrace ("Web API", -2) (T.pack e)
+      let logE e = logEvent (ErrorTrace Error) ("Web API", -2) (T.pack e)
       let s' =
-            s & runtimeState
-              %~ case eport of
-                Right p -> (webPort ?~ p) . (eventLog %~ logP p)
-                Left e -> eventLog %~ logE e
+            s
+              & runtimeState
+                %~ case eport of
+                  Right p -> (webPort ?~ p) . (eventLog %~ logP p)
+                  Left e -> eventLog %~ logE e
 
       -- Update the reference for every event
       let eventHandler e = do
             curSt <- get
-            liftIO $ writeIORef gsRef (curSt ^. gameState)
+            liftIO $ writeIORef appStateRef curSt
             handleEvent e
 
-      -- Run the app.
-      let buildVty = V.mkVty V.defaultConfig
+      -- Setup virtual terminal
+      let buildVty = V.mkVty $ V.defaultConfig {V.colorMode = colorMode opts}
       initialVty <- buildVty
       V.setMode (V.outputIface initialVty) V.Mouse True
+
+      -- Run the app.
       void $ customMain initialVty buildVty (Just chan) (app eventHandler) s'
 
 -- | A demo program to run the web service directly, without the terminal application.
@@ -111,14 +115,15 @@
           , scriptToRun = Nothing
           , autoPlay = False
           , cheatMode = False
+          , colorMode = Nothing
           , userWebPort = Nothing
           , repoGitInfo = Nothing
           }
   case res of
     Left errMsg -> T.putStrLn errMsg
     Right s -> do
-      gsRef <- newIORef (s ^. gameState)
-      webMain Nothing demoPort gsRef
+      appStateRef <- newIORef s
+      webMain Nothing demoPort appStateRef
  where
   demoScenario = Just "./data/scenarios/Testing/475-wait-one.yaml"
 
diff --git a/src/Swarm/DocGen.hs b/src/Swarm/DocGen.hs
--- a/src/Swarm/DocGen.hs
+++ b/src/Swarm/DocGen.hs
@@ -45,7 +45,7 @@
 import Swarm.Game.Entity (Entity, EntityMap (entitiesByName), entityDisplay, entityName, loadEntities)
 import Swarm.Game.Entity qualified as E
 import Swarm.Game.Recipe (Recipe, loadRecipes, recipeInputs, recipeOutputs, recipeRequirements, recipeTime, recipeWeight)
-import Swarm.Game.Robot (installedDevices, instantiateRobot, robotInventory)
+import Swarm.Game.Robot (equippedDevices, instantiateRobot, robotInventory)
 import Swarm.Game.Scenario (Scenario, loadScenario, scenarioRobots)
 import Swarm.Game.WorldGen (testWorld2Entites)
 import Swarm.Language.Capability (Capability)
@@ -54,7 +54,7 @@
 import Swarm.Language.Syntax (Const (..))
 import Swarm.Language.Syntax qualified as Syntax
 import Swarm.Language.Typecheck (inferConst)
-import Swarm.Util (getDataFileNameSafe, isRightOr)
+import Swarm.Util (getDataFileNameSafe, isRightOr, listEnums, quote)
 import Text.Dot (Dot, NodeId, (.->.))
 import Text.Dot qualified as Dot
 import Witch (from)
@@ -105,7 +105,7 @@
               putStrLn $ "-- " <> show et
               putStrLn $ replicate 40 '-'
               generateEditorKeywords et
-        mapM_ editorGen [minBound .. maxBound]
+        mapM_ editorGen listEnums
   CheatSheet address s -> case s of
     Nothing -> error "Not implemented for all Wikis"
     Just st -> case st of
@@ -161,8 +161,6 @@
 editorList = \case
   Emacs -> T.unlines . map (("  " <>) . quote)
   VSCode -> T.intercalate "|"
- where
-  quote = T.cons '"' . flip T.snoc '"'
 
 constSyntax :: Const -> Text
 constSyntax = Syntax.syntax . Syntax.constInfo
@@ -306,7 +304,7 @@
   header = [listToRow mw capabilityHeader, separatingLine mw]
 
 capabilityPage :: PageAddress -> EntityMap -> Text
-capabilityPage a em = capabilityTable a em [minBound .. maxBound]
+capabilityPage a em = capabilityTable a em listEnums
 
 -- ---------
 -- Entities
@@ -321,7 +319,7 @@
     escapeTable
     [ codeQuote . T.singleton $ e ^. entityDisplay . to displayChar
     , addLink ("#" <> linkID) $ view entityName e
-    , T.intercalate ", " $ Capability.capabilityName <$> view E.entityCapabilities e
+    , T.intercalate ", " $ Capability.capabilityName <$> Set.toList (view E.entityCapabilities e)
     , T.intercalate ", " . map tshow . filter (/= E.Portable) $ toList props
     , if E.Portable `elem` props
         then ":heavy_check_mark:"
@@ -351,7 +349,7 @@
       <> [T.intercalate "\n\n" $ view E.entityDescription e]
  where
   props = view E.entityProperties e
-  caps = view E.entityCapabilities e
+  caps = Set.toList $ view E.entityCapabilities e
 
 entitiesPage :: PageAddress -> [Entity] -> Text
 entitiesPage _a es =
@@ -415,9 +413,9 @@
   world <- diamond "World"
   base <- diamond "Base"
   -- --------------------------------------------------------------------------
-  -- add nodes with for all the known entites
+  -- add nodes with for all the known entities
   let enames' = toList . Map.keysSet . entitiesByName $ emap
-      enames = filter (`Set.notMember` ignoredEntites) enames'
+      enames = filter (`Set.notMember` ignoredEntities) enames'
   ebmap <- Map.fromList . zip enames <$> mapM (box . unpack) enames
   -- --------------------------------------------------------------------------
   -- getters for the NodeId based on entity name or the whole entity
@@ -425,7 +423,7 @@
       getE = safeGetEntity ebmap
       nid = getE . view entityName
   -- --------------------------------------------------------------------------
-  -- Get the starting inventories, entites present in the world and compute
+  -- Get the starting inventories, entities present in the world and compute
   -- how hard each entity is to get - see 'recipeLevels'.
   let devs = startingDevices classic
       inv = startingInventory classic
@@ -439,13 +437,13 @@
     mapM_ ((base ---<>) . nid) devs
     mapM_ ((base .->.) . nid . fst) $ Map.toList inv
   -- --------------------------------------------------------------------------
-  -- World entites
+  -- World entities
   (_wc, ()) <- Dot.cluster $ do
     Dot.attribute ("style", "filled")
     Dot.attribute ("color", "forestgreen")
     mapM_ ((uncurry (Dot..->.) . (world,)) . getE) (toList testWorld2Entites)
   -- --------------------------------------------------------------------------
-  let -- put a hidden node above and below entites and connect them by hidden edges
+  let -- put a hidden node above and below entities and connect them by hidden edges
       wrapBelowAbove :: Set Entity -> Dot (NodeId, NodeId)
       wrapBelowAbove ns = do
         b <- hiddenNode
@@ -454,7 +452,7 @@
         mapM_ (b .~>.) ns'
         mapM_ (.~>. t) ns'
         return (b, t)
-      -- put set of entites in nice
+      -- put set of entities in nice
       subLevel :: Int -> Set Entity -> Dot (NodeId, NodeId)
       subLevel i ns = fmap snd . Dot.cluster $ do
         Dot.attribute ("style", "filled")
@@ -471,7 +469,7 @@
             ]
         return bt
   -- --------------------------------------------------------------------------
-  -- order entites into clusters based on how "far" they are from
+  -- order entities into clusters based on how "far" they are from
   -- what is available at the start - see 'recipeLevels'.
   bottom <- wrapBelowAbove worldEntites
   ls <- zipWithM subLevel [1 ..] (tail levels)
@@ -495,14 +493,14 @@
 -- RECIPE LEVELS
 -- ----------------------------------------------------------------------------
 
--- | Order entites in sets depending on how soon it is possible to obtain them.
+-- | Order entities in sets depending on how soon it is possible to obtain them.
 --
 -- So:
---  * Level 0 - starting entites (for example those obtainable in the world)
+--  * Level 0 - starting entities (for example those obtainable in the world)
 --  * Level N+1 - everything possible to make (or drill) from Level N
 --
--- This is almost a BFS, but the requirement is that the set of entites
--- required for recipe is subset of the entites known in Level N.
+-- This is almost a BFS, but the requirement is that the set of entities
+-- required for recipe is subset of the entities known in Level N.
 --
 -- If we ever depend on some graph library, this could be rewritten
 -- as some BFS-like algorithm with added recipe nodes, but you would
@@ -524,21 +522,21 @@
             then ls
             else go (n : ls) (Set.union n known)
 
--- | Get classic scenario to figure out starting entites.
+-- | Get classic scenario to figure out starting entities.
 classicScenario :: ExceptT Text IO Scenario
 classicScenario = do
   entities <- loadEntities >>= guardRight "load entities"
   fst <$> loadScenario "data/scenarios/classic.yaml" entities
 
 startingDevices :: Scenario -> Set Entity
-startingDevices = Set.fromList . map snd . E.elems . view installedDevices . instantiateRobot 0 . head . view scenarioRobots
+startingDevices = Set.fromList . map snd . E.elems . view equippedDevices . instantiateRobot 0 . head . view scenarioRobots
 
 startingInventory :: Scenario -> Map Entity Int
 startingInventory = Map.fromList . map swap . E.elems . view robotInventory . instantiateRobot 0 . head . view scenarioRobots
 
--- | Ignore utility entites that are just used for tutorials and challenges.
-ignoredEntites :: Set Text
-ignoredEntites =
+-- | Ignore utility entities that are just used for tutorials and challenges.
+ignoredEntities :: Set Text
+ignoredEntities =
   Set.fromList
     [ "upper left corner"
     , "upper right corner"
diff --git a/src/Swarm/Game/CESK.hs b/src/Swarm/Game/CESK.hs
--- a/src/Swarm/Game/CESK.hs
+++ b/src/Swarm/Game/CESK.hs
@@ -1,5 +1,5 @@
-{-# LANGUAGE DeriveAnyClass #-}
 {-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE PatternSynonyms #-}
 
 -- |
@@ -64,7 +64,7 @@
 
   -- * Store
   Store,
-  Loc,
+  Addr,
   emptyStore,
   Cell (..),
   allocate,
@@ -82,31 +82,26 @@
 
   -- ** Extracting information
   finalValue,
-
-  -- ** Pretty-printing
-  prettyFrame,
-  prettyCont,
-  prettyCESK,
 ) where
 
+import Control.Lens ((^.))
 import Control.Lens.Combinators (pattern Empty)
 import Data.Aeson (FromJSON, ToJSON)
-import Data.Aeson qualified
 import Data.IntMap.Strict (IntMap)
 import Data.IntMap.Strict qualified as IM
-import Data.List (intercalate)
 import GHC.Generics (Generic)
-import Swarm.Game.Entity (Entity, Inventory)
+import Prettyprinter (Doc, Pretty (..), hsep, (<+>))
+import Swarm.Game.Entity (Count, Entity)
 import Swarm.Game.Exception
-import Swarm.Game.Value as V
-import Swarm.Game.World (World)
+import Swarm.Game.World (WorldUpdate (..))
 import Swarm.Language.Context
+import Swarm.Language.Module
 import Swarm.Language.Pipeline
 import Swarm.Language.Pretty
 import Swarm.Language.Requirement (ReqCtx)
 import Swarm.Language.Syntax
 import Swarm.Language.Types
-import Witch (from)
+import Swarm.Language.Value as V
 
 ------------------------------------------------------------
 -- Frames and continuations
@@ -163,12 +158,21 @@
     --   in the given environment (extended by binding the variable,
     --   if there is one, to the output of the first command).
     FBind (Maybe Var) Term Env
+  | -- | Discard any environment generated as the result of executing
+    --   a command.
+    FDiscardEnv
   | -- | Apply specific updates to the world and current robot.
-    FImmediate WorldUpdate RobotUpdate
+    --
+    -- The 'Const' is used to track the original command for error messages.
+    FImmediate Const [WorldUpdate Entity] [RobotUpdate]
   | -- | Update the memory cell at a certain location with the computed value.
-    FUpdate Loc
+    FUpdate Addr
   | -- | Signal that we are done with an atomic computation.
     FFinishAtomic
+  | -- | We are in the middle of running a computation for all the
+    --   nearby robots.  We have the function to run, and the list of
+    --   robot IDs to run it on.
+    FMeetAll Value [Int]
   deriving (Eq, Show, Generic, FromJSON, ToJSON)
 
 -- | A continuation is just a stack of frames.
@@ -178,10 +182,10 @@
 -- Store
 ------------------------------------------------------------
 
-type Loc = Int
+type Addr = Int
 
 -- | 'Store' represents a store, indexing integer locations to 'Cell's.
-data Store = Store {next :: Loc, mu :: IntMap Cell} deriving (Show, Eq, Generic, FromJSON, ToJSON)
+data Store = Store {next :: Addr, mu :: IntMap Cell} deriving (Show, Eq, Generic, FromJSON, ToJSON)
 
 -- | A memory cell can be in one of three states.
 data Cell
@@ -213,15 +217,15 @@
 -- | Allocate a new memory cell containing an unevaluated expression
 --   with the current environment.  Return the index of the allocated
 --   cell.
-allocate :: Env -> Term -> Store -> (Loc, Store)
+allocate :: Env -> Term -> Store -> (Addr, Store)
 allocate e t (Store n m) = (n, Store (n + 1) (IM.insert n (E t e) m))
 
 -- | Look up the cell at a given index.
-lookupCell :: Loc -> Store -> Maybe Cell
+lookupCell :: Addr -> Store -> Maybe Cell
 lookupCell n = IM.lookup n . mu
 
 -- | Set the cell at a given index.
-setCell :: Loc -> Cell -> Store -> Store
+setCell :: Addr -> Cell -> Store -> Store
 setCell n c (Store nxt m) = Store nxt (IM.insert n c m)
 
 ------------------------------------------------------------
@@ -281,11 +285,25 @@
 
 -- | Like 'initMachine', but also take an explicit starting continuation.
 initMachine' :: ProcessedTerm -> Env -> Store -> Cont -> CESK
-initMachine' (ProcessedTerm t (Module (Forall _ (TyCmd _)) ctx) _ reqCtx) e s k =
-  case ctx of
-    Empty -> In t e s (FExec : k)
-    _ -> In t e s (FExec : FLoadEnv ctx reqCtx : k)
-initMachine' (ProcessedTerm t _ _ _) e s k = In t e s k
+initMachine' (ProcessedTerm (Module t' ctx) _ reqCtx) e s k =
+  case t' ^. sType of
+    -- If the starting term has a command type...
+    Forall _ (TyCmd _) ->
+      case ctx of
+        -- ...but doesn't contain any definitions, just create a machine
+        -- that will evaluate it and then execute it.
+        Empty -> In t e s (FExec : k)
+        -- Or, if it does contain definitions, then load the resulting
+        -- context after executing it.
+        _ -> In t e s (FExec : FLoadEnv ctx reqCtx : k)
+    -- Otherwise, for a term with a non-command type, just
+    -- create a machine to evaluate it.
+    _ -> In t e s k
+ where
+  -- Erase all type and SrcLoc annotations from the term before
+  -- putting it in the machine state, since those are irrelevant at
+  -- runtime.
+  t = eraseS t'
 
 -- | Cancel the currently running computation.
 cancel :: CESK -> CESK
@@ -307,82 +325,73 @@
   resetBlackhole c = c
 
 ------------------------------------------------------------
--- Very crude pretty-printing of CESK states.  Should really make a
--- nicer version of this code...
+-- Pretty printing CESK machine states
 ------------------------------------------------------------
 
--- | Very poor pretty-printing of CESK machine states, really just for
---   debugging. At some point we should make a nicer version.
-prettyCESK :: CESK -> String
-prettyCESK (In c _ _ k) =
-  unlines
-    [ "▶ " ++ prettyString c
-    , "  " ++ prettyCont k
-    ]
-prettyCESK (Out v _ k) =
-  unlines
-    [ "◀ " ++ from (prettyValue v)
-    , "  " ++ prettyCont k
-    ]
-prettyCESK (Up e _ k) =
-  unlines
-    [ "! " ++ from (formatExn mempty e)
-    , "  " ++ prettyCont k
-    ]
-prettyCESK (Waiting t cek) =
-  "🕑" <> show t <> " " <> show cek
-
--- | Poor pretty-printing of continuations.
-prettyCont :: Cont -> String
-prettyCont = ("[" ++) . (++ "]") . intercalate " | " . map prettyFrame
-
--- | Poor pretty-printing of frames.
-prettyFrame :: Frame -> String
-prettyFrame (FSnd t _) = "(_, " ++ prettyString t ++ ")"
-prettyFrame (FFst v) = "(" ++ from (prettyValue v) ++ ", _)"
-prettyFrame (FArg t _) = "_ " ++ prettyString t
-prettyFrame (FApp v) = prettyString (valueToTerm v) ++ " _"
-prettyFrame (FLet x t _) = "let " ++ from x ++ " = _ in " ++ prettyString t
-prettyFrame (FTry t) = "try _ (" ++ prettyString (valueToTerm t) ++ ")"
-prettyFrame FUnionEnv {} = "_ ∪ <Env>"
-prettyFrame FLoadEnv {} = "loadEnv"
-prettyFrame (FDef x) = "def " ++ from x ++ " = _"
-prettyFrame FExec = "exec _"
-prettyFrame (FBind Nothing t _) = "_ ; " ++ prettyString t
-prettyFrame (FBind (Just x) t _) = from x ++ " <- _ ; " ++ prettyString t
-prettyFrame FImmediate {} = "(_ : cmd a)"
-prettyFrame (FUpdate loc) = "store@" ++ show loc ++ "(_)"
-prettyFrame FFinishAtomic = "finishAtomic"
+instance PrettyPrec CESK where
+  prettyPrec _ (In c _ _ k) = prettyCont k (11, "▶" <> ppr c <> "◀")
+  prettyPrec _ (Out v _ k) = prettyCont k (11, "◀" <> ppr (valueToTerm v) <> "▶")
+  prettyPrec _ (Up e _ k) = prettyCont k (11, "!" <> (pretty (formatExn mempty e) <> "!"))
+  prettyPrec _ (Waiting t cesk) = "🕑" <> pretty t <> "(" <> ppr cesk <> ")"
 
---------------------------------------------------------------
--- Wrappers for functions in FImmediate
---
--- NOTE: we can not use GameState and Robot directly, as it
--- would create a cyclic dependency. The alternative is
--- making CESK, Cont and Frame polymorphic which just muddies
--- the picture too much for one little game feature.
+-- | Take a continuation, and the pretty-printed expression which is
+--   the focus of the continuation (i.e. the expression whose value
+--   will be given to the continuation) along with its top-level
+--   precedence, and pretty-print the whole thing.
 --
--- BEWARE: the types do not follow normal laws for Show and Eq
---------------------------------------------------------------
-
-newtype WorldUpdate = WorldUpdate
-  { worldUpdate :: World Int Entity -> Either Exn (World Int Entity)
-  }
-
-newtype RobotUpdate = RobotUpdate
-  { robotUpdateInventory :: Inventory -> Inventory
-  }
-
-instance Show WorldUpdate where show _ = "WorldUpdate {???}"
+--   As much as possible, we try to print to look like an *expression*
+--   with a currently focused part, that is, we print the continuation
+--   from the inside out instead of as a list of frames.  This makes
+--   it much more intuitive to read.
+prettyCont :: Cont -> (Int, Doc ann) -> Doc ann
+prettyCont [] (_, inner) = inner
+prettyCont (f : k) inner = prettyCont k (prettyFrame f inner)
 
-instance Show RobotUpdate where show _ = "RobotUpdate {???}"
+-- | Pretty-print a single continuation frame, given its already
+--   pretty-printed focus.  In particular, given a frame and its
+--   "inside" (i.e. the expression or other frames being focused on,
+--   whose value will eventually be passed to this frame), with the
+--   precedence of the inside's top-level construct, return a
+--   pretty-printed version of the entire frame along with its
+--   top-level precedence.
+prettyFrame :: Frame -> (Int, Doc ann) -> (Int, Doc ann)
+prettyFrame (FSnd t _) (_, inner) = (11, "(" <> inner <> "," <+> ppr t <> ")")
+prettyFrame (FFst v) (_, inner) = (11, "(" <> ppr (valueToTerm v) <> "," <+> inner <> ")")
+prettyFrame (FArg t _) (p, inner) = (10, pparens (p < 10) inner <+> prettyPrec 11 t)
+prettyFrame (FApp v) (p, inner) = (10, prettyPrec 10 (valueToTerm v) <+> pparens (p < 11) inner)
+prettyFrame (FLet x t _) (_, inner) = (11, hsep ["let", pretty x, "=", inner, "in", ppr t])
+prettyFrame (FTry v) (p, inner) = (10, "try" <+> pparens (p < 11) inner <+> prettyPrec 11 (valueToTerm v))
+prettyFrame (FUnionEnv _) inner = prettyPrefix "∪·" inner
+prettyFrame (FLoadEnv _ _) inner = prettyPrefix "L·" inner
+prettyFrame (FDef x) (_, inner) = (11, "def" <+> pretty x <+> "=" <+> inner <+> "end")
+prettyFrame FExec inner = prettyPrefix "E·" inner
+prettyFrame (FBind Nothing t _) (p, inner) = (0, pparens (p < 1) inner <+> ";" <+> ppr t)
+prettyFrame (FBind (Just x) t _) (p, inner) = (0, hsep [pretty x, "<-", pparens (p < 1) inner, ";", ppr t])
+prettyFrame FDiscardEnv inner = prettyPrefix "D·" inner
+prettyFrame (FImmediate c _worldUpds _robotUpds) inner = prettyPrefix ("I[" <> ppr c <> "]·") inner
+prettyFrame (FUpdate addr) inner = prettyPrefix ("S@" <> pretty addr) inner
+prettyFrame FFinishAtomic inner = prettyPrefix "A·" inner
+prettyFrame (FMeetAll _ _) inner = prettyPrefix "M·" inner
 
-instance Eq WorldUpdate where _ == _ = True
+-- | Pretty-print a special "prefix application" frame, i.e. a frame
+--   formatted like @X· inner@.  Unlike typical applications, these
+--   associate to the *right*, so that we can print something like @X·
+--   Y· Z· inner@ with no parens.
+prettyPrefix :: Doc ann -> (Int, Doc ann) -> (Int, Doc ann)
+prettyPrefix pre (p, inner) = (11, pre <+> pparens (p < 11) inner)
 
-instance Eq RobotUpdate where _ == _ = True
+--------------------------------------------------------------
+-- Runtime robot update
+--------------------------------------------------------------
 
--- TODO: remove these instances once Update fields are concret
-instance FromJSON WorldUpdate where parseJSON _ = pure $ WorldUpdate $ \w -> Right w
-instance ToJSON WorldUpdate where toJSON _ = Data.Aeson.Null
-instance FromJSON RobotUpdate where parseJSON _ = pure $ RobotUpdate id
-instance ToJSON RobotUpdate where toJSON _ = Data.Aeson.Null
+-- | Update the robot in an inspectable way.
+--
+-- This type is used for changes by e.g. the drill command at later
+-- tick. Using ADT allows us to serialize and inspect the updates.
+--
+-- Note that this can not be in 'Swarm.Game.Robot' as it would create
+-- a cyclic dependency.
+data RobotUpdate
+  = AddEntity Count Entity
+  | LearnEntity Entity
+  deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)
diff --git a/src/Swarm/Game/Display.hs b/src/Swarm/Game/Display.hs
--- a/src/Swarm/Game/Display.hs
+++ b/src/Swarm/Game/Display.hs
@@ -44,9 +44,9 @@
 import Data.Maybe (fromMaybe, isJust)
 import Data.Yaml
 import GHC.Generics (Generic)
-import Swarm.Language.Syntax (Direction (..))
+import Swarm.Language.Syntax (AbsoluteDir (..), Direction (..))
 import Swarm.TUI.Attr (entityAttr, robotAttr, worldPrefix)
-import Swarm.Util (maxOn, (?))
+import Swarm.Util (maxOn)
 import Swarm.Util.Yaml (FromJSONE (..), With (runE), getE, liftE, withObjectE)
 
 -- | Display priority.  Entities with higher priority will be drawn on
@@ -60,7 +60,7 @@
 -- | A record explaining how to display an entity in the TUI.
 data Display = Display
   { _defaultChar :: Char
-  , _orientationMap :: Map Direction Char
+  , _orientationMap :: Map AbsoluteDir Char
   , _curOrientation :: Maybe Direction
   , _displayAttr :: AttrName
   , _displayPriority :: Priority
@@ -83,7 +83,7 @@
 --   optionally associates different display characters with
 --   different orientations.  If an orientation is not in the map,
 --   the 'defaultChar' will be used.
-orientationMap :: Lens' Display (Map Direction Char)
+orientationMap :: Lens' Display (Map AbsoluteDir Char)
 
 -- | The display caches the current orientation of the entity, so we
 --   know which character to use from the orientation map.
@@ -128,9 +128,9 @@
 
 -- | Look up the character that should be used for a display.
 displayChar :: Display -> Char
-displayChar disp = case disp ^. curOrientation of
-  Nothing -> disp ^. defaultChar
-  Just dir -> M.lookup dir (disp ^. orientationMap) ? (disp ^. defaultChar)
+displayChar disp = fromMaybe (disp ^. defaultChar) $ do
+  DAbsolute d <- disp ^. curOrientation
+  M.lookup d (disp ^. orientationMap)
 
 -- | Render a display as a UI widget.
 renderDisplay :: Display -> Widget n
@@ -167,7 +167,7 @@
 --   priority 10.
 --
 --   Note that the 'defaultChar' is used for direction 'DDown'
---   and is overriden for the special base robot.
+--   and is overridden for the special base robot.
 defaultRobotDisplay :: Display
 defaultRobotDisplay =
   Display
diff --git a/src/Swarm/Game/Entity.hs b/src/Swarm/Game/Entity.hs
--- a/src/Swarm/Game/Entity.hs
+++ b/src/Swarm/Game/Entity.hs
@@ -69,6 +69,8 @@
   isSubsetOf,
   isEmpty,
   inventoryCapabilities,
+  extantElemsWithCapability,
+  entitiesByCapability,
 
   -- ** Modification
   insert,
@@ -81,32 +83,31 @@
 ) where
 
 import Control.Arrow ((&&&))
-import Control.Lens (Getter, Lens', lens, to, view, (^.), _2)
+import Control.Lens (Getter, Lens', lens, to, view, (^.))
 import Control.Monad.IO.Class
 import Data.Bifunctor (bimap, first)
 import Data.Char (toLower)
 import Data.Function (on)
 import Data.Hashable
-import Data.Int (Int64)
 import Data.IntMap (IntMap)
 import Data.IntMap qualified as IM
 import Data.IntSet (IntSet)
 import Data.IntSet qualified as IS
 import Data.List (foldl')
+import Data.List.NonEmpty qualified as NE
 import Data.Map (Map)
 import Data.Map qualified as M
 import Data.Maybe (fromMaybe, isJust, listToMaybe)
 import Data.Set (Set)
-import Data.Set qualified as Set (fromList)
-import Data.Set.Lens (setOf)
+import Data.Set qualified as Set (fromList, member, toList, unions)
 import Data.Text (Text)
 import Data.Text qualified as T
 import Data.Yaml
 import GHC.Generics (Generic)
-import Linear (V2)
 import Swarm.Game.Display
 import Swarm.Language.Capability
-import Swarm.Util (dataNotFound, getDataFileNameSafe, plural, reflow, (?))
+import Swarm.Util (binTuples, dataNotFound, getDataFileNameSafe, plural, reflow, (?))
+import Swarm.Util.Location
 import Swarm.Util.Yaml
 import Text.Read (readMaybe)
 import Witch
@@ -198,33 +199,33 @@
 --   entities stored in the world that are the same will literally
 --   just be stored as pointers to the same shared record.
 data Entity = Entity
-  { -- | A hash value computed from the other fields
-    _entityHash :: Int
-  , -- | The way this entity should be displayed on the world map.
-    _entityDisplay :: Display
-  , -- | The name of the entity, used /e.g./ in an inventory display.
-    _entityName :: Text
-  , -- | The plural of the entity name, in case it is irregular.  If
-    --   this field is @Nothing@, default pluralization heuristics
-    --   will be used (see 'plural').
-    _entityPlural :: Maybe Text
-  , -- | A longer-form description. Each 'Text' value is one
-    --   paragraph.
-    _entityDescription :: [Text]
-  , -- | The entity's orientation (if it has one).  For example, when
-    --   a robot moves, it moves in the direction of its orientation.
-    _entityOrientation :: Maybe (V2 Int64)
-  , -- | If this entity grows, how long does it take?
-    _entityGrowth :: Maybe GrowthTime
-  , -- | The name of a different entity obtained when this entity is
-    -- grabbed.
-    _entityYields :: Maybe Text
-  , -- | Properties of the entity.
-    _entityProperties :: Set EntityProperty
-  , -- | Capabilities provided by this entity.
-    _entityCapabilities :: [Capability]
-  , -- | Inventory of other entities held by this entity.
-    _entityInventory :: Inventory
+  { _entityHash :: Int
+  -- ^ A hash value computed from the other fields
+  , _entityDisplay :: Display
+  -- ^ The way this entity should be displayed on the world map.
+  , _entityName :: Text
+  -- ^ The name of the entity, used /e.g./ in an inventory display.
+  , _entityPlural :: Maybe Text
+  -- ^ The plural of the entity name, in case it is irregular.  If
+  --   this field is @Nothing@, default pluralization heuristics
+  --   will be used (see 'plural').
+  , _entityDescription :: [Text]
+  -- ^ A longer-form description. Each 'Text' value is one
+  --   paragraph.
+  , _entityOrientation :: Maybe Heading
+  -- ^ The entity's orientation (if it has one).  For example, when
+  --   a robot moves, it moves in the direction of its orientation.
+  , _entityGrowth :: Maybe GrowthTime
+  -- ^ If this entity grows, how long does it take?
+  , _entityYields :: Maybe Text
+  -- ^ The name of a different entity obtained when this entity is
+  -- grabbed.
+  , _entityProperties :: Set EntityProperty
+  -- ^ Properties of the entity.
+  , _entityCapabilities :: Set Capability
+  -- ^ Capabilities provided by this entity.
+  , _entityInventory :: Inventory
+  -- ^ Inventory of other entities held by this entity.
   }
   -- Note that an entity does not have a location, because the
   -- location of an entity is implicit in the way it is stored (by
@@ -236,7 +237,8 @@
 --   value and simply combines the other fields.
 instance Hashable Entity where
   hashWithSalt s (Entity _ disp nm pl descr orient grow yld props caps inv) =
-    s `hashWithSalt` disp
+    s
+      `hashWithSalt` disp
       `hashWithSalt` nm
       `hashWithSalt` pl
       `hashWithSalt` descr
@@ -275,7 +277,7 @@
   [Capability] ->
   Entity
 mkEntity disp nm descr props caps =
-  rehashEntity $ Entity 0 disp nm Nothing descr Nothing Nothing Nothing (Set.fromList props) caps empty
+  rehashEntity $ Entity 0 disp nm Nothing descr Nothing Nothing Nothing (Set.fromList props) (Set.fromList caps) empty
 
 ------------------------------------------------------------
 -- Entity map
@@ -291,7 +293,7 @@
   deriving (Eq, Show, Generic, FromJSON, ToJSON)
 
 instance Semigroup EntityMap where
-  EntityMap n1 c1 <> EntityMap n2 c2 = EntityMap (n1 <> n2) (c1 <> c2)
+  EntityMap n1 c1 <> EntityMap n2 c2 = EntityMap (n1 <> n2) (M.unionWith (<>) c1 c2)
 
 instance Monoid EntityMap where
   mempty = EntityMap M.empty M.empty
@@ -313,7 +315,7 @@
 buildEntityMap es =
   EntityMap
     { entitiesByName = M.fromList . map (view entityName &&& id) $ es
-    , entitiesByCap = M.fromListWith (<>) . concatMap (\e -> map (,[e]) (e ^. entityCapabilities)) $ es
+    , entitiesByCap = M.fromListWith (<>) . concatMap (\e -> map (,[e]) (Set.toList $ e ^. entityCapabilities)) $ es
     }
 
 ------------------------------------------------------------
@@ -332,7 +334,7 @@
               <*> v .:? "growth"
               <*> v .:? "yields"
               <*> v .:? "properties" .!= mempty
-              <*> v .:? "capabilities" .!= []
+              <*> v .:? "capabilities" .!= mempty
               <*> pure empty
           )
 
@@ -419,7 +421,7 @@
 entityDescription = hashedLens _entityDescription (\e x -> e {_entityDescription = x})
 
 -- | The direction this entity is facing (if it has one).
-entityOrientation :: Lens' Entity (Maybe (V2 Int64))
+entityOrientation :: Lens' Entity (Maybe Heading)
 entityOrientation = hashedLens _entityOrientation (\e x -> e {_entityOrientation = x})
 
 -- | How long this entity takes to grow, if it regrows.
@@ -439,8 +441,8 @@
 hasProperty :: Entity -> EntityProperty -> Bool
 hasProperty e p = p `elem` (e ^. entityProperties)
 
--- | The capabilities this entity provides when installed.
-entityCapabilities :: Lens' Entity [Capability]
+-- | The capabilities this entity provides when equipped.
+entityCapabilities :: Lens' Entity (Set Capability)
 entityCapabilities = hashedLens _entityCapabilities (\e x -> e {_entityCapabilities = x})
 
 -- | The inventory of other entities carried by this entity.
@@ -567,7 +569,25 @@
 
 -- | Compute the set of capabilities provided by the devices in an inventory.
 inventoryCapabilities :: Inventory -> Set Capability
-inventoryCapabilities = setOf (to elems . traverse . _2 . entityCapabilities . traverse)
+inventoryCapabilities = Set.unions . map (^. entityCapabilities) . nonzeroEntities
+
+-- | List elements that have at least one copy in the inventory.
+nonzeroEntities :: Inventory -> [Entity]
+nonzeroEntities = map snd . filter ((> 0) . fst) . elems
+
+-- | List elements that possess a given Capability and
+-- exist with nonzero count in the inventory.
+extantElemsWithCapability :: Capability -> Inventory -> [Entity]
+extantElemsWithCapability cap =
+  filter (Set.member cap . (^. entityCapabilities)) . nonzeroEntities
+
+-- | Groups entities by the capabilities they offer.
+entitiesByCapability :: Inventory -> Map Capability (NE.NonEmpty Entity)
+entitiesByCapability inv =
+  binTuples entityCapabilityPairs
+ where
+  getCaps = Set.toList . (^. entityCapabilities)
+  entityCapabilityPairs = concatMap ((\e -> map (,e) $ getCaps e) . snd) $ elems inv
 
 -- | Delete a single copy of a certain entity from an inventory.
 delete :: Entity -> Inventory -> Inventory
diff --git a/src/Swarm/Game/Exception.hs b/src/Swarm/Game/Exception.hs
--- a/src/Swarm/Game/Exception.hs
+++ b/src/Swarm/Game/Exception.hs
@@ -1,4 +1,3 @@
-{-# LANGUAGE DeriveAnyClass #-}
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 
@@ -32,6 +31,7 @@
 import Swarm.Language.Pretty (prettyText)
 import Swarm.Language.Requirement (Requirements (..))
 import Swarm.Language.Syntax (Const, Term)
+import Swarm.TUI.Model.Achievement.Definitions
 import Swarm.Util
 import Witch (from)
 
@@ -52,8 +52,8 @@
 
 -- | Suggested way to fix incapable error.
 data IncapableFix
-  = -- | Install the missing device on yourself/target
-    FixByInstall
+  = -- | Equip the missing device on yourself/target
+    FixByEquip
   | -- | Add the missing device to your inventory
     FixByObtain
   deriving (Eq, Show, Generic, FromJSON, ToJSON)
@@ -74,7 +74,7 @@
   | -- | A command failed in some "normal" way (/e.g./ a 'Move'
     --   command could not move, or a 'Grab' command found nothing to
     --   grab, /etc./).
-    CmdFailed Const Text
+    CmdFailed Const Text (Maybe GameplayAchievement)
   | -- | The user program explicitly called 'Undefined' or 'Fail'.
     User Text
   deriving (Eq, Show, Generic, FromJSON, ToJSON)
@@ -89,7 +89,7 @@
       , "<https://github.com/swarm-game/swarm/issues/new>."
       ]
   InfiniteLoop -> "Infinite loop detected!"
-  (CmdFailed c t) -> T.concat [prettyText c, ": ", t]
+  (CmdFailed c t _) -> T.concat [prettyText c, ": ", t]
   (User t) -> "Player exception: " <> t
   (Incapable f caps tm) -> formatIncapable em f caps tm
 
@@ -99,7 +99,7 @@
 
 formatIncapableFix :: IncapableFix -> Text
 formatIncapableFix = \case
-  FixByInstall -> "install"
+  FixByEquip -> "equip"
   FixByObtain -> "obtain"
 
 -- | Pretty print the incapable exception with an actionable suggestion
@@ -108,7 +108,7 @@
 -- >>> w = mkEntity (defaultEntityDisplay 'l') "magic wand" [] [] [CAppear]
 -- >>> r = mkEntity (defaultEntityDisplay 'o') "the one ring" [] [] [CAppear]
 -- >>> m = buildEntityMap [w,r]
--- >>> incapableError cs t = putStr . unpack $ formatIncapable m FixByInstall cs t
+-- >>> incapableError cs t = putStr . unpack $ formatIncapable m FixByEquip cs t
 --
 -- >>> incapableError (R.singletonCap CGod) (TConst As)
 -- Thou shalt not utter such blasphemy:
@@ -118,7 +118,7 @@
 -- >>> incapableError (R.singletonCap CAppear) (TConst Appear)
 -- You do not have the devices required for:
 --   'appear'
---   Please install:
+--   Please equip:
 --   - the one ring or magic wand
 --
 -- >>> incapableError (R.singletonCap CRandom) (TConst Random)
@@ -135,32 +135,32 @@
 formatIncapable :: EntityMap -> IncapableFix -> Requirements -> Term -> Text
 formatIncapable em f (Requirements caps _ inv) tm
   | CGod `S.member` caps =
-    unlinesExText
-      [ "Thou shalt not utter such blasphemy:"
-      , squote $ prettyText tm
-      , "If God in troth thou wantest to play, try thou a Creative game."
-      ]
+      unlinesExText
+        [ "Thou shalt not utter such blasphemy:"
+        , squote $ prettyText tm
+        , "If God in troth thou wantest to play, try thou a Creative game."
+        ]
   | not (null capsNone) =
-    unlinesExText
-      [ "Missing the " <> capMsg <> " for:"
-      , squote $ prettyText tm
-      , "but no device yet provides it. See"
-      , "https://github.com/swarm-game/swarm/issues/26"
-      ]
+      unlinesExText
+        [ "Missing the " <> capMsg <> " for:"
+        , squote $ prettyText tm
+        , "but no device yet provides it. See"
+        , "https://github.com/swarm-game/swarm/issues/26"
+        ]
   | not (S.null caps) =
-    unlinesExText
-      ( "You do not have the devices required for:" :
-        squote (prettyText tm) :
-        "Please " <> formatIncapableFix f <> ":" :
-        (("- " <>) . formatDevices <$> filter (not . null) deviceSets)
-      )
+      unlinesExText
+        ( "You do not have the devices required for:"
+            : squote (prettyText tm)
+            : "Please " <> formatIncapableFix f <> ":"
+            : (("- " <>) . formatDevices <$> filter (not . null) deviceSets)
+        )
   | otherwise =
-    unlinesExText
-      ( "You are missing required inventory for:" :
-        squote (prettyText tm) :
-        "Please obtain:" :
-        (("- " <>) . formatEntity <$> M.assocs inv)
-      )
+      unlinesExText
+        ( "You are missing required inventory for:"
+            : squote (prettyText tm)
+            : "Please obtain:"
+            : (("- " <>) . formatEntity <$> M.assocs inv)
+        )
  where
   capList = S.toList caps
   deviceSets = map (`deviceForCap` em) capList
diff --git a/src/Swarm/Game/Log.hs b/src/Swarm/Game/Log.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Log.hs
@@ -0,0 +1,74 @@
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE TemplateHaskell #-}
+
+-- |
+-- Module      :  Swarm.Game.Log
+-- Copyright   :  Ondřej Šebek
+-- Maintainer  :  ondras98@icloud.com
+--
+-- SPDX-License-Identifier: BSD-3-Clause
+--
+-- A data type to represent in-game logs by robots.
+--
+-- Because of the use of system robots, we sometimes
+-- want to use special kinds of logs that will be
+-- shown to the player.
+--
+-- TODO: #1039 Currently we abuse this system for system
+-- logs, which is fun, but we should eventually make
+-- a dedicated `SystemLogEntry` type for 'RuntimeState'
+-- message queue.
+module Swarm.Game.Log (
+  LogSource (..),
+  ErrorLevel (..),
+
+  -- * Robot log entries
+  LogEntry (..),
+  leText,
+  leSource,
+  leRobotName,
+  leTime,
+  leLocation,
+  leRobotID,
+) where
+
+import Control.Lens hiding (contains)
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Swarm.Util.Location (Location)
+
+-- | Severity of the error - critical errors are bugs
+--   and should be reported as Issues.
+data ErrorLevel = Debug | Warning | Error | Critical
+  deriving (Show, Eq, Ord, Generic, FromJSON, ToJSON)
+
+-- | Source of the robot log.
+data LogSource
+  = -- | Log produced by 'Swarm.Language.Syntax.Say'
+    Said
+  | -- | Log produced by 'Swarm.Language.Syntax.Log'
+    Logged
+  | -- | Log produced by an exception or system.
+    ErrorTrace ErrorLevel
+  deriving (Show, Eq, Ord, Generic, FromJSON, ToJSON)
+
+-- | An entry in a robot's log.
+data LogEntry = LogEntry
+  { _leTime :: Integer
+  -- ^ The time at which the entry was created.
+  --   Note that this is the first field we sort on.
+  , _leSource :: LogSource
+  -- ^ Whether this log records a said message.
+  , _leRobotName :: Text
+  -- ^ The name of the robot that generated the entry.
+  , _leRobotID :: Int
+  -- ^ The ID of the robot that generated the entry.
+  , _leLocation :: Location
+  -- ^ Location of the robot at log entry creation.
+  , _leText :: Text
+  -- ^ The text of the log entry.
+  }
+  deriving (Show, Eq, Ord, Generic, FromJSON, ToJSON)
+
+makeLenses ''LogEntry
diff --git a/src/Swarm/Game/Recipe.hs b/src/Swarm/Game/Recipe.hs
--- a/src/Swarm/Game/Recipe.hs
+++ b/src/Swarm/Game/Recipe.hs
@@ -1,4 +1,3 @@
-{-# LANGUAGE DeriveAnyClass #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TemplateHaskell #-}
 
@@ -191,7 +190,7 @@
 
 -- | Figure out which ingredients (if any) are lacking from an
 --   inventory to be able to carry out the recipe.
---   Requirements are not consumed and so can use installed.
+--   Requirements are not consumed and so can use equipped.
 missingIngredientsFor :: (Inventory, Inventory) -> Recipe Entity -> [MissingIngredient]
 missingIngredientsFor (inv, ins) (Recipe inps _ reqs _ _) =
   mkMissing MissingInput (findLacking inv inps)
@@ -214,7 +213,7 @@
 --   or an inventory without inputs and function adding outputs if
 --   it was successful.
 make ::
-  -- robots inventory and installed devices
+  -- robots inventory and equipped devices
   (Inventory, Inventory) ->
   -- considered recipe
   Recipe Entity ->
@@ -222,11 +221,10 @@
   -- a function to add results and the recipe repeated
   Either
     [MissingIngredient]
-    (Inventory, Inventory -> Inventory, Recipe Entity)
+    (Inventory, IngredientList Entity, Recipe Entity)
 make invs r = finish <$> make' invs r
  where
-  finish (invTaken, out) = (invTaken, addOuts out, r)
-  addOuts out inv' = foldl' (flip $ uncurry insertCount) inv' out
+  finish (invTaken, out) = (invTaken, out, r)
 
 -- | Try to make a recipe, but do not insert it yet.
 make' :: (Inventory, Inventory) -> Recipe Entity -> Either [MissingIngredient] (Inventory, IngredientList Entity)
diff --git a/src/Swarm/Game/Robot.hs b/src/Swarm/Game/Robot.hs
--- a/src/Swarm/Game/Robot.hs
+++ b/src/Swarm/Game/Robot.hs
@@ -1,5 +1,4 @@
 {-# LANGUAGE DataKinds #-}
-{-# LANGUAGE DeriveAnyClass #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE TemplateHaskell #-}
@@ -19,14 +18,7 @@
   -- * Robots data
 
   -- * Robot log entries
-  LogSource (..),
-  LogEntry (..),
-  leText,
-  leSaid,
-  leRobotName,
-  leTime,
-  leLocation,
-  leRobotID,
+  module Swarm.Game.Log,
 
   -- * Robots
   RobotPhase (..),
@@ -35,6 +27,9 @@
   Robot,
   TRobot,
 
+  -- ** Runtime robot update
+  RobotUpdate (..),
+
   -- * Robot context
   RobotContext,
   defTypes,
@@ -54,12 +49,13 @@
   trobotLocation,
   robotOrientation,
   robotInventory,
-  installedDevices,
+  equippedDevices,
   robotLog,
   robotLogUpdated,
   inventoryHash,
   robotCapabilities,
   robotContext,
+  trobotContext,
   robotID,
   robotParentID,
   robotHeavy,
@@ -86,7 +82,6 @@
 import Control.Lens hiding (contains)
 import Data.Aeson (FromJSON, ToJSON)
 import Data.Hashable (hashWithSalt)
-import Data.Int (Int64)
 import Data.Maybe (fromMaybe, isNothing)
 import Data.Sequence (Seq)
 import Data.Sequence qualified as Seq
@@ -98,32 +93,33 @@
 import Swarm.Game.CESK
 import Swarm.Game.Display (Display, curOrientation, defaultRobotDisplay, invisible)
 import Swarm.Game.Entity hiding (empty)
-import Swarm.Game.Value as V
+import Swarm.Game.Log
 import Swarm.Language.Capability (Capability)
 import Swarm.Language.Context qualified as Ctx
 import Swarm.Language.Requirement (ReqCtx)
 import Swarm.Language.Syntax (toDirection)
 import Swarm.Language.Typed (Typed (..))
 import Swarm.Language.Types (TCtx)
-import Swarm.Util ()
+import Swarm.Language.Value as V
+import Swarm.Util.Location
 import Swarm.Util.Yaml
 import System.Clock (TimeSpec)
 
 -- | A record that stores the information
---   for all defintions stored in a 'Robot'
+--   for all definitions stored in a 'Robot'
 data RobotContext = RobotContext
-  { -- | Map definition names to their types.
-    _defTypes :: TCtx
-  , -- | Map defintion names to the capabilities
-    --   required to evaluate/execute them.
-    _defReqs :: ReqCtx
-  , -- | Map defintion names to their values. Note that since
-    --   definitions are delayed, the values will just consist of
-    --   'VRef's pointing into the store.
-    _defVals :: Env
-  , -- | A store containing memory cells allocated to hold
-    --   definitions.
-    _defStore :: Store
+  { _defTypes :: TCtx
+  -- ^ Map definition names to their types.
+  , _defReqs :: ReqCtx
+  -- ^ Map definition names to the capabilities
+  --   required to evaluate/execute them.
+  , _defVals :: Env
+  -- ^ Map definition names to their values. Note that since
+  --   definitions are delayed, the values will just consist of
+  --   'VRef's pointing into the store.
+  , _defStore :: Store
+  -- ^ A store containing memory cells allocated to hold
+  --   definitions.
   }
   deriving (Eq, Show, Generic, FromJSON, ToJSON)
 
@@ -146,37 +142,16 @@
         req <- Ctx.lookup name (ctx ^. defReqs)
         return $ Typed val typ req
     setter ctx Nothing =
-      ctx & defTypes %~ Ctx.delete name
+      ctx
+        & defTypes %~ Ctx.delete name
         & defVals %~ Ctx.delete name
         & defReqs %~ Ctx.delete name
     setter ctx (Just (Typed val typ req)) =
-      ctx & defTypes %~ Ctx.addBinding name typ
+      ctx
+        & defTypes %~ Ctx.addBinding name typ
         & defVals %~ Ctx.addBinding name val
         & defReqs %~ Ctx.addBinding name req
 
-data LogSource = Said | Logged | ErrorTrace
-  deriving (Show, Eq, Ord, Generic, FromJSON, ToJSON)
-
--- | An entry in a robot's log.
-data LogEntry = LogEntry
-  { -- | The time at which the entry was created.
-    --   Note that this is the first field we sort on.
-    _leTime :: Integer
-  , -- | Whether this log records a said message.
-    _leSaid :: LogSource
-  , -- | The name of the robot that generated the entry.
-    _leRobotName :: Text
-  , -- | The ID of the robot that generated the entry.
-    _leRobotID :: Int
-  , -- | Location of the robot at log entry creation.
-    _leLocation :: V2 Int64
-  , -- | The text of the log entry.
-    _leText :: Text
-  }
-  deriving (Show, Eq, Ord, Generic, FromJSON, ToJSON)
-
-makeLenses ''LogEntry
-
 -- | A unique identifier for a robot.
 type RID = Int
 
@@ -192,8 +167,8 @@
 -- | With a robot template, we may or may not have a location.  With a
 --   concrete robot we must have a location.
 type family RobotLocation (phase :: RobotPhase) :: * where
-  RobotLocation 'TemplateRobot = Maybe (V2 Int64)
-  RobotLocation 'ConcreteRobot = V2 Int64
+  RobotLocation 'TemplateRobot = Maybe Location
+  RobotLocation 'ConcreteRobot = Location
 
 -- | Robot templates have no ID; concrete robots definitely do.
 type family RobotID (phase :: RobotPhase) :: * where
@@ -205,10 +180,10 @@
 --   the robot has been assigned a unique ID.
 data RobotR (phase :: RobotPhase) = RobotR
   { _robotEntity :: Entity
-  , _installedDevices :: Inventory
-  , -- | A cached view of the capabilities this robot has.
-    --   Automatically generated from '_installedDevices'.
-    _robotCapabilities :: Set Capability
+  , _equippedDevices :: Inventory
+  , _robotCapabilities :: Set Capability
+  -- ^ A cached view of the capabilities this robot has.
+  --   Automatically generated from '_equippedDevices'.
   , _robotLog :: Seq LogEntry
   , _robotLogUpdated :: Bool
   , _robotLocation :: RobotLocation phase
@@ -233,7 +208,7 @@
 -- See https://byorgey.wordpress.com/2021/09/17/automatically-updated-cached-views-with-lens/
 -- for the approach used here with lenses.
 
-let exclude = ['_robotCapabilities, '_installedDevices, '_robotLog]
+let exclude = ['_robotCapabilities, '_equippedDevices, '_robotLog]
  in makeLensesWith
       ( lensRules
           & generateSignatures .~ False
@@ -299,32 +274,36 @@
 --   a getter, since when changing a robot's location we must remember
 --   to update the 'robotsByLocation' map as well.  You can use the
 --   'updateRobotLocation' function for this purpose.
-robotLocation :: Getter Robot (V2 Int64)
+robotLocation :: Getter Robot Location
 
 -- | Set a robot's location.  This is unsafe and should never be
 --   called directly except by the 'updateRobotLocation' function.
 --   The reason is that we need to make sure the 'robotsByLocation'
 --   map stays in sync.
-unsafeSetRobotLocation :: V2 Int64 -> Robot -> Robot
+unsafeSetRobotLocation :: Location -> Robot -> Robot
 unsafeSetRobotLocation loc r = r {_robotLocation = loc}
 
 -- | A template robot's location.  Unlike 'robotLocation', this is a
 --   lens, since when dealing with robot templates there is as yet no
 --   'robotsByLocation' map to keep up-to-date.
-trobotLocation :: Lens' TRobot (Maybe (V2 Int64))
+trobotLocation :: Lens' TRobot (Maybe Location)
 trobotLocation = lens _robotLocation (\r l -> r {_robotLocation = l})
 
 -- | Which way the robot is currently facing.
-robotOrientation :: Lens' Robot (Maybe (V2 Int64))
+robotOrientation :: Lens' Robot (Maybe Heading)
 robotOrientation = robotEntity . entityOrientation
 
 -- | The robot's inventory.
 robotInventory :: Lens' Robot Inventory
 robotInventory = robotEntity . entityInventory
 
--- | The robot's context
+-- | The robot's context.
 robotContext :: Lens' Robot RobotContext
 
+-- | The robot's context.
+trobotContext :: Lens' TRobot RobotContext
+trobotContext = lens _robotContext (\r c -> r {_robotContext = c})
+
 -- | The (unique) ID number of the robot.  This is only a Getter since
 --   the robot ID is immutable.
 robotID :: Getter Robot RID
@@ -338,7 +317,7 @@
 instantiateRobot i r =
   r
     { _robotID = i
-    , _robotLocation = fromMaybe (V2 0 0) (_robotLocation r)
+    , _robotLocation = fromMaybe zero (_robotLocation r)
     }
 
 -- | The ID number of the robot's parent, that is, the robot that
@@ -349,19 +328,19 @@
 -- | Is this robot extra heavy (thus requiring tank treads to move)?
 robotHeavy :: Lens' Robot Bool
 
--- | A separate inventory for "installed devices", which provide the
+-- | A separate inventory for equipped devices, which provide the
 --   robot with certain capabilities.
 --
---   Note that every time the inventory of installed devices is
+--   Note that every time the inventory of equipped devices is
 --   modified, this lens recomputes a cached set of the capabilities
---   the installed devices provide, to speed up subsequent lookups to
+--   the equipped devices provide, to speed up subsequent lookups to
 --   see whether the robot has a certain capability (see 'robotCapabilities')
-installedDevices :: Lens' Robot Inventory
-installedDevices = lens _installedDevices setInstalled
+equippedDevices :: Lens' Robot Inventory
+equippedDevices = lens _equippedDevices setEquipped
  where
-  setInstalled r inst =
+  setEquipped r inst =
     r
-      { _installedDevices = inst
+      { _equippedDevices = inst
       , _robotCapabilities = inventoryCapabilities inst
       }
 
@@ -390,20 +369,20 @@
 --   viewed?
 robotLogUpdated :: Lens' Robot Bool
 
--- | A hash of a robot's entity record and installed devices, to
+-- | A hash of a robot's entity record and equipped devices, to
 --   facilitate quickly deciding whether we need to redraw the robot
 --   info panel.
 inventoryHash :: Getter Robot Int
-inventoryHash = to (\r -> 17 `hashWithSalt` (r ^. (robotEntity . entityHash)) `hashWithSalt` (r ^. installedDevices))
+inventoryHash = to (\r -> 17 `hashWithSalt` (r ^. (robotEntity . entityHash)) `hashWithSalt` (r ^. equippedDevices))
 
 -- | Does a robot know of an entity's existence?
 robotKnows :: Robot -> Entity -> Bool
-robotKnows r e = contains0plus e (r ^. robotInventory) || contains0plus e (r ^. installedDevices)
+robotKnows r e = contains0plus e (r ^. robotInventory) || contains0plus e (r ^. equippedDevices)
 
 -- | Get the set of capabilities this robot possesses.  This is only a
 --   getter, not a lens, because it is automatically generated from
---   the 'installedDevices'.  The only way to change a robot's
---   capabilities is to modify its 'installedDevices'.
+--   the 'equippedDevices'.  The only way to change a robot's
+--   capabilities is to modify its 'equippedDevices'.
 robotCapabilities :: Getter Robot (Set Capability)
 robotCapabilities = to _robotCapabilities
 
@@ -472,12 +451,12 @@
   -- | Initial location.
   RobotLocation phase ->
   -- | Initial heading/direction.
-  V2 Int64 ->
+  Heading ->
   -- | Robot display.
   Display ->
   -- | Initial CESK machine.
   CESK ->
-  -- | Installed devices.
+  -- | Equipped devices.
   [Entity] ->
   -- | Initial inventory.
   [(Count, Entity)] ->
@@ -494,7 +473,7 @@
         mkEntity disp name descr [] []
           & entityOrientation ?~ dir
           & entityInventory .~ fromElems inv
-    , _installedDevices = inst
+    , _equippedDevices = inst
     , _robotCapabilities = inventoryCapabilities inst
     , _robotLog = Seq.empty
     , _robotLogUpdated = False
diff --git a/src/Swarm/Game/Scenario.hs b/src/Swarm/Game/Scenario.hs
--- a/src/Swarm/Game/Scenario.hs
+++ b/src/Swarm/Game/Scenario.hs
@@ -1,5 +1,4 @@
 {-# LANGUAGE DefaultSignatures #-}
-{-# LANGUAGE DeriveAnyClass #-}
 {-# LANGUAGE DerivingVia #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
@@ -17,14 +16,11 @@
 -- conditions, which can be used both for building interactive
 -- tutorials and for standalone puzzles and scenarios.
 module Swarm.Game.Scenario (
-  -- * Objectives
-  Objective,
-  objectiveGoal,
-  objectiveCondition,
-
   -- * WorldDescription
-  Cell (..),
-  WorldDescription (..),
+  PCell (..),
+  Cell,
+  PWorldDescription (..),
+  WorldDescription,
   IndexedTRobot,
 
   -- * Scenario
@@ -37,6 +33,7 @@
   scenarioDescription,
   scenarioCreative,
   scenarioSeed,
+  scenarioAttrs,
   scenarioEntities,
   scenarioRecipes,
   scenarioKnown,
@@ -56,183 +53,29 @@
 import Control.Carrier.Lift (Lift, sendIO)
 import Control.Carrier.Throw.Either (Throw, throwError)
 import Control.Lens hiding (from, (<.>))
-import Control.Monad (filterM, when)
-import Control.Monad.Extra (mapMaybeM)
-import Data.Aeson.Key qualified as Key
-import Data.Aeson.KeyMap (KeyMap)
-import Data.Aeson.KeyMap qualified as KeyMap
-import Data.Map (Map)
-import Data.Map qualified as M
+import Control.Monad (filterM)
+import Data.Aeson
 import Data.Maybe (catMaybes, isNothing, listToMaybe)
 import Data.Text (Text)
 import Data.Text qualified as T
-import Data.Vector qualified as V
 import Data.Yaml as Y
-import GHC.Generics (Generic)
-import GHC.Int (Int64)
-import Linear.V2
 import Swarm.Game.Entity
 import Swarm.Game.Recipe
-import Swarm.Game.Robot (TRobot, trobotName)
-import Swarm.Game.Terrain
+import Swarm.Game.Robot (TRobot)
+import Swarm.Game.Scenario.Cell
+import Swarm.Game.Scenario.Objective
+import Swarm.Game.Scenario.Objective.Validation
+import Swarm.Game.Scenario.RobotLookup
+import Swarm.Game.Scenario.Style
+import Swarm.Game.Scenario.WorldDescription
 import Swarm.Language.Pipeline (ProcessedTerm)
-import Swarm.Util (getDataFileNameSafe, reflow)
+import Swarm.Util (getDataFileNameSafe)
 import Swarm.Util.Yaml
 import System.Directory (doesFileExist)
 import System.FilePath ((<.>), (</>))
 import Witch (from, into)
 
 ------------------------------------------------------------
--- Scenario objectives
-------------------------------------------------------------
-
--- | An objective is a condition to be achieved by a player in a
---   scenario.
-data Objective = Objective
-  { _objectiveGoal :: [Text]
-  , _objectiveCondition :: ProcessedTerm
-  }
-  deriving (Eq, Show, Generic, ToJSON)
-
-makeLensesWith (lensRules & generateSignatures .~ False) ''Objective
-
--- | An explanation of the goal of the objective, shown to the player
---   during play.  It is represented as a list of paragraphs.
-objectiveGoal :: Lens' Objective [Text]
-
--- | A winning condition for the objective, expressed as a
---   program of type @cmd bool@.  By default, this program will be
---   run to completion every tick (the usual limits on the number
---   of CESK steps per tick do not apply).
-objectiveCondition :: Lens' Objective ProcessedTerm
-
-instance FromJSON Objective where
-  parseJSON = withObject "objective" $ \v ->
-    Objective
-      <$> (fmap . map) reflow (v .:? "goal" .!= [])
-      <*> (v .: "condition")
-
-------------------------------------------------------------
--- Robot map
-------------------------------------------------------------
-
--- | A robot template paired with its definition's index within
--- the Scenario file
-type IndexedTRobot = (Int, TRobot)
-
--- | A map from names to robots, used to look up robots in scenario
---   descriptions.
-type RobotMap = Map Text IndexedTRobot
-
--- | Create a 'RobotMap' from a list of robot templates.
-buildRobotMap :: [TRobot] -> RobotMap
-buildRobotMap rs = M.fromList $ zipWith (\x y -> (view trobotName y, (x, y))) [0 ..] rs
-
-------------------------------------------------------------
--- Lookup utilities
-------------------------------------------------------------
-
--- | Look up a thing by name, throwing a parse error if it is not
---   found.
-getThing :: String -> (Text -> m -> Maybe a) -> Text -> ParserE m a
-getThing thing lkup name = do
-  m <- getE
-  case lkup name m of
-    Nothing -> fail $ "Unknown " <> thing <> " name: " ++ show name
-    Just a -> return a
-
--- | Look up an entity by name in an 'EntityMap', throwing a parse
---   error if it is not found.
-getEntity :: Text -> ParserE EntityMap Entity
-getEntity = getThing "entity" lookupEntityName
-
--- | Look up a robot by name in a 'RobotMap', throwing a parse error
---   if it is not found.
-getRobot :: Text -> ParserE RobotMap IndexedTRobot
-getRobot = getThing "robot" M.lookup
-
-------------------------------------------------------------
--- World cells
-------------------------------------------------------------
-
--- | A single cell in a world map, which contains a terrain value,
---   and optionally an entity and robot.
-data Cell = Cell
-  { cellTerrain :: TerrainType
-  , cellEntity :: Maybe Entity
-  , cellRobots :: [IndexedTRobot]
-  }
-  deriving (Eq, Show)
-
--- | Parse a tuple such as @[grass, rock, base]@ into a 'Cell'.  The
---   entity and robot, if present, are immediately looked up and
---   converted into 'Entity' and 'TRobot' values.  If they are not
---   found, a parse error results.
-instance FromJSONE (EntityMap, RobotMap) Cell where
-  parseJSONE = withArrayE "tuple" $ \v -> do
-    let tup = V.toList v
-    when (null tup) $ fail "palette entry must nonzero length (terrain, optional entity and then robots if any)"
-
-    terr <- liftE $ parseJSON (head tup)
-
-    ent <- case tup ^? ix 1 of
-      Nothing -> return Nothing
-      Just e -> do
-        meName <- liftE $ parseJSON @(Maybe Text) e
-        traverse (localE fst . getEntity) meName
-
-    let name2rob r = do
-          mrName <- liftE $ parseJSON @(Maybe Text) r
-          traverse (localE snd . getRobot) mrName
-
-    robs <- mapMaybeM name2rob (drop 2 tup)
-
-    return $ Cell terr ent robs
-
-------------------------------------------------------------
--- World description
-------------------------------------------------------------
-
--- | A world palette maps characters to 'Cell' values.
-newtype WorldPalette = WorldPalette
-  {unPalette :: KeyMap Cell}
-  deriving (Eq, Show)
-
-instance FromJSONE (EntityMap, RobotMap) WorldPalette where
-  parseJSONE = withObjectE "palette" $ fmap WorldPalette . mapM parseJSONE
-
--- | A description of a world parsed from a YAML file.
-data WorldDescription = WorldDescription
-  { defaultTerrain :: Maybe Cell
-  , offsetOrigin :: Bool
-  , palette :: WorldPalette
-  , ul :: V2 Int64
-  , area :: [[Cell]]
-  }
-  deriving (Eq, Show)
-
-instance FromJSONE (EntityMap, RobotMap) WorldDescription where
-  parseJSONE = withObjectE "world description" $ \v -> do
-    pal <- v ..:? "palette" ..!= WorldPalette mempty
-    WorldDescription
-      <$> v ..:? "default"
-      <*> liftE (v .:? "offset" .!= False)
-      <*> pure pal
-      <*> liftE (v .:? "upperleft" .!= V2 0 0)
-      <*> liftE ((v .:? "map" .!= "") >>= paintMap pal)
-
--- | "Paint" a world map using a 'WorldPalette', turning it from a raw
---   string into a nested list of 'Cell' values by looking up each
---   character in the palette, failing if any character in the raw map
---   is not contained in the palette.
-paintMap :: MonadFail m => WorldPalette -> Text -> m [[Cell]]
-paintMap pal = traverse (traverse toCell . into @String) . T.lines
- where
-  toCell c = case KeyMap.lookup (Key.fromString [c]) (unPalette pal) of
-    Nothing -> fail $ "Char not in world palette: " ++ show c
-    Just cell -> return cell
-
-------------------------------------------------------------
 -- Scenario
 ------------------------------------------------------------
 
@@ -245,6 +88,7 @@
   , _scenarioDescription :: Text
   , _scenarioCreative :: Bool
   , _scenarioSeed :: Maybe Int
+  , _scenarioAttrs :: [CustomAttr]
   , _scenarioEntities :: EntityMap
   , _scenarioRecipes :: [Recipe Entity]
   , _scenarioKnown :: [Text]
@@ -263,6 +107,7 @@
     -- parse custom entities
     em <- liftE (buildEntityMap <$> (v .:? "entities" .!= []))
     -- extend ambient EntityMap with custom entities
+
     withE em $ do
       -- parse 'known' entity names and make sure they exist
       known <- liftE (v .:? "known" .!= [])
@@ -284,12 +129,13 @@
         <*> liftE (v .:? "description" .!= "")
         <*> liftE (v .:? "creative" .!= False)
         <*> liftE (v .:? "seed")
+        <*> liftE (v .:? "attrs" .!= [])
         <*> pure em
         <*> v ..:? "recipes" ..!= []
         <*> pure known
         <*> localE (,rsMap) (v ..: "world")
         <*> pure rs
-        <*> liftE (v .:? "objectives" .!= [])
+        <*> (liftE (v .:? "objectives" .!= []) >>= validateObjectives)
         <*> liftE (v .:? "solution")
         <*> liftE (v .:? "stepsPerTick")
 
@@ -318,6 +164,9 @@
 -- | The seed used for the random number generator.  If @Nothing@, use
 --   a random seed / prompt the user for the seed.
 scenarioSeed :: Lens' Scenario (Maybe Int)
+
+-- | Custom attributes defined in the scenario.
+scenarioAttrs :: Lens' Scenario [CustomAttr]
 
 -- | Any custom entities used for this scenario.
 scenarioEntities :: Lens' Scenario EntityMap
diff --git a/src/Swarm/Game/Scenario/Cell.hs b/src/Swarm/Game/Scenario/Cell.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/Cell.hs
@@ -0,0 +1,62 @@
+{-# LANGUAGE DerivingVia #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Swarm.Game.Scenario.Cell (
+  PCell (..),
+  Cell,
+) where
+
+import Control.Lens hiding (from, (<.>))
+import Control.Monad (when)
+import Control.Monad.Extra (mapMaybeM)
+import Data.Text (Text)
+import Data.Vector qualified as V
+import Data.Yaml as Y
+import Swarm.Game.Entity
+import Swarm.Game.Scenario.RobotLookup
+import Swarm.Game.Terrain
+import Swarm.Util.Yaml
+
+------------------------------------------------------------
+-- World cells
+------------------------------------------------------------
+
+-- | A single cell in a world map, which contains a terrain value,
+--   and optionally an entity and robot.
+--   It is parameterized on the Entity type to facilitate less
+--   stateful versions of the Entity type in rendering scenario data.
+data PCell e = Cell
+  { cellTerrain :: TerrainType
+  , cellEntity :: Maybe e
+  , cellRobots :: [IndexedTRobot]
+  }
+  deriving (Eq, Show)
+
+-- | A single cell in a world map, which contains a terrain value,
+--   and optionally an entity and robot.
+type Cell = PCell Entity
+
+-- | Parse a tuple such as @[grass, rock, base]@ into a 'Cell'.  The
+--   entity and robot, if present, are immediately looked up and
+--   converted into 'Entity' and 'TRobot' values.  If they are not
+--   found, a parse error results.
+instance FromJSONE (EntityMap, RobotMap) Cell where
+  parseJSONE = withArrayE "tuple" $ \v -> do
+    let tup = V.toList v
+    when (null tup) $ fail "palette entry must nonzero length (terrain, optional entity and then robots if any)"
+
+    terr <- liftE $ parseJSON (head tup)
+
+    ent <- case tup ^? ix 1 of
+      Nothing -> return Nothing
+      Just e -> do
+        meName <- liftE $ parseJSON @(Maybe Text) e
+        traverse (localE fst . getEntity) meName
+
+    let name2rob r = do
+          mrName <- liftE $ parseJSON @(Maybe Text) r
+          traverse (localE snd . getRobot) mrName
+
+    robs <- mapMaybeM name2rob (drop 2 tup)
+
+    return $ Cell terr ent robs
diff --git a/src/Swarm/Game/Scenario/Objective.hs b/src/Swarm/Game/Scenario/Objective.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/Objective.hs
@@ -0,0 +1,195 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+module Swarm.Game.Scenario.Objective where
+
+import Control.Applicative ((<|>))
+import Control.Lens hiding (from, (<.>))
+import Data.Aeson
+import Data.Set qualified as Set
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Swarm.Game.Scenario.Objective.Logic as L
+import Swarm.Language.Pipeline (ProcessedTerm)
+import Swarm.TUI.Model.Achievement.Definitions
+import Swarm.Util (reflow)
+
+------------------------------------------------------------
+-- Scenario objectives
+------------------------------------------------------------
+
+data PrerequisiteConfig = PrerequisiteConfig
+  { previewable :: Bool
+  -- ^ Typically, only the currently "active" objectives are
+  -- displayed to the user in the Goals dialog. An objective
+  -- is "active" if all of its prerequisites are met.
+  --
+  -- However, some objectives may be "high-level", in that they may
+  -- explain the broader intention behind potentially multiple
+  -- prerequisites.
+  --
+  -- Set this to option True to display this goal in the "upcoming" section even
+  -- if the objective has currently unmet prerequisites.
+  , logic :: Prerequisite ObjectiveLabel
+  -- ^ Boolean expression of dependencies upon other objectives. Variables in this expression
+  -- are the "id"s of other objectives, and become "true" if the corresponding objective is completed.
+  -- The "condition" of the objective at hand shall not be evaluated until its
+  -- prerequisite expression evaluates as True.
+  --
+  -- Note that the achievement of these objective dependencies is
+  -- persistent; once achieved, they still count even if their "condition"
+  -- might not still hold. The condition is never re-evaluated once True.
+  }
+  deriving (Eq, Show, Generic, ToJSON)
+
+instance FromJSON PrerequisiteConfig where
+  -- Parsing JSON/YAML 'PrerequisiteConfig' has a shorthand option
+  -- in which the boolean expression can be written directly,
+  -- bypassing the "logic" key.
+  -- Furthermore, an "Id" in a boolean expressions can be written
+  -- as a bare string without needing the "id" key.
+  parseJSON val = preLogic val <|> preObject val
+   where
+    preObject = withObject "prerequisite" $ \v ->
+      PrerequisiteConfig
+        <$> (v .:? "previewable" .!= False)
+        <*> v .: "logic"
+    preLogic = fmap (PrerequisiteConfig False) . parseJSON
+
+-- | An objective is a condition to be achieved by a player in a
+--   scenario.
+data Objective = Objective
+  { _objectiveGoal :: [Text]
+  , _objectiveTeaser :: Maybe Text
+  , _objectiveCondition :: ProcessedTerm
+  , _objectiveId :: Maybe ObjectiveLabel
+  , _objectiveOptional :: Bool
+  , _objectivePrerequisite :: Maybe PrerequisiteConfig
+  , _objectiveHidden :: Bool
+  , _objectiveAchievement :: Maybe AchievementInfo
+  }
+  deriving (Eq, Show, Generic, ToJSON)
+
+makeLensesWith (lensRules & generateSignatures .~ False) ''Objective
+
+-- | An explanation of the goal of the objective, shown to the player
+--   during play.  It is represented as a list of paragraphs.
+objectiveGoal :: Lens' Objective [Text]
+
+-- | A very short (3-5 words) description of the goal for
+-- displaying on the left side of the Objectives modal.
+objectiveTeaser :: Lens' Objective (Maybe Text)
+
+-- | A winning condition for the objective, expressed as a
+--   program of type @cmd bool@.  By default, this program will be
+--   run to completion every tick (the usual limits on the number
+--   of CESK steps per tick do not apply).
+objectiveCondition :: Lens' Objective ProcessedTerm
+
+-- | Optional name by which this objective may be referenced
+-- as a prerequisite for other objectives.
+objectiveId :: Lens' Objective (Maybe Text)
+
+-- | Indicates whether the objective is not required in order
+-- to "win" the scenario. Useful for (potentially hidden) achievements.
+-- If the field is not supplied, it defaults to False (i.e. the
+-- objective is mandatory to "win").
+objectiveOptional :: Lens' Objective Bool
+
+-- | Dependencies upon other objectives
+objectivePrerequisite :: Lens' Objective (Maybe PrerequisiteConfig)
+
+-- | Whether the goal is displayed in the UI before completion.
+-- The goal will always be revealed after it is completed.
+--
+-- This attribute often goes along with an Achievement.
+objectiveHidden :: Lens' Objective Bool
+
+-- | An optional Achievement that is to be registered globally
+-- when this objective is completed.
+objectiveAchievement :: Lens' Objective (Maybe AchievementInfo)
+
+instance FromJSON Objective where
+  parseJSON = withObject "objective" $ \v ->
+    Objective
+      <$> (fmap . map) reflow (v .:? "goal" .!= [])
+      <*> (v .:? "teaser")
+      <*> (v .: "condition")
+      <*> (v .:? "id")
+      <*> (v .:? "optional" .!= False)
+      <*> (v .:? "prerequisite")
+      <*> (v .:? "hidden" .!= False)
+      <*> (v .:? "achievement")
+
+data CompletionBuckets = CompletionBuckets
+  { incomplete :: [Objective]
+  , completed :: [Objective]
+  , unwinnable :: [Objective]
+  }
+  deriving (Show, Generic, FromJSON, ToJSON)
+
+data ObjectiveCompletion = ObjectiveCompletion
+  { completionBuckets :: CompletionBuckets
+  -- ^ This is the authoritative "completion status"
+  -- for all objectives.
+  -- Note that there is a separate Set to store the
+  -- completion status of prerequisite objectives, which
+  -- must be carefully kept in sync with this.
+  -- Those prerequisite objectives are required to have
+  -- labels, but other objectives are not.
+  -- Therefore only prerequisites exist in the completion
+  -- map keyed by label.
+  , completedIDs :: Set.Set ObjectiveLabel
+  }
+  deriving (Show, Generic, FromJSON, ToJSON)
+
+-- | Concatenates all incomplete and completed objectives.
+listAllObjectives :: CompletionBuckets -> [Objective]
+listAllObjectives (CompletionBuckets x y z) = x <> y <> z
+
+addCompleted :: Objective -> ObjectiveCompletion -> ObjectiveCompletion
+addCompleted obj (ObjectiveCompletion buckets cmplIds) =
+  ObjectiveCompletion newBuckets newCmplById
+ where
+  newBuckets =
+    buckets
+      { completed = obj : completed buckets
+      }
+  newCmplById = case _objectiveId obj of
+    Nothing -> cmplIds
+    Just lbl -> Set.insert lbl cmplIds
+
+addUnwinnable :: Objective -> ObjectiveCompletion -> ObjectiveCompletion
+addUnwinnable obj (ObjectiveCompletion buckets cmplIds) =
+  ObjectiveCompletion newBuckets cmplIds
+ where
+  newBuckets =
+    buckets
+      { unwinnable = obj : unwinnable buckets
+      }
+
+setIncomplete ::
+  ([Objective] -> [Objective]) ->
+  ObjectiveCompletion ->
+  ObjectiveCompletion
+setIncomplete f (ObjectiveCompletion buckets cmplIds) =
+  ObjectiveCompletion newBuckets cmplIds
+ where
+  newBuckets =
+    buckets
+      { incomplete = f $ incomplete buckets
+      }
+
+addIncomplete :: Objective -> ObjectiveCompletion -> ObjectiveCompletion
+addIncomplete obj = setIncomplete (obj :)
+
+-- | Returns the "ObjectiveCompletion" with the "incomplete" goals
+-- extracted to a separate tuple member.
+-- This is intended as input to a "fold".
+extractIncomplete :: ObjectiveCompletion -> (ObjectiveCompletion, [Objective])
+extractIncomplete oc =
+  (withoutIncomplete, incompleteGoals)
+ where
+  incompleteGoals = incomplete $ completionBuckets oc
+  withoutIncomplete = setIncomplete (const []) oc
diff --git a/src/Swarm/Game/Scenario/Objective/Graph.hs b/src/Swarm/Game/Scenario/Objective/Graph.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/Objective/Graph.hs
@@ -0,0 +1,144 @@
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+-- | Utilities for performing graph analysis on Objective prerequisites
+module Swarm.Game.Scenario.Objective.Graph where
+
+import Control.Arrow ((&&&))
+import Data.Aeson
+import Data.BoolExpr (Signed (Positive))
+import Data.BoolExpr qualified as BE
+import Data.Graph (Graph, SCC (AcyclicSCC), graphFromEdges, stronglyConnComp)
+import Data.Map (Map)
+import Data.Map.Strict qualified as M
+import Data.Maybe (mapMaybe)
+import Data.Set qualified as Set
+import Data.Text qualified as T
+import Data.Tuple (swap)
+import GHC.Generics (Generic)
+import Swarm.Game.Scenario.Objective
+import Swarm.Game.Scenario.Objective.Logic as L
+import Swarm.Game.Scenario.Objective.WinCheck
+
+-- | This is only needed for constructing a Graph,
+-- which requires all nodes to have a key.
+data ObjectiveId
+  = Label (Signed ObjectiveLabel)
+  | -- | for unlabeled objectives
+    Ordinal Int
+  deriving (Eq, Ord, Show, Generic, ToJSON)
+
+data GraphInfo = GraphInfo
+  { actualGraph :: Graph
+  , isAcyclic :: Bool
+  , sccInfo :: [SCC Objective]
+  , nodeIDs :: [ObjectiveId]
+  }
+  deriving (Show, Generic, ToJSON)
+
+instance ToJSON (SCC Objective) where
+  toJSON = String . T.pack . show
+
+instance ToJSON Graph where
+  toJSON = String . T.pack . show
+
+getConstFromSigned :: BE.Signed a -> a
+getConstFromSigned = \case
+  BE.Positive x -> x
+  BE.Negative x -> x
+
+-- | Collect all of the constants that have a negation.
+-- This is necessary for enumerating all of the distinct
+-- nodes when constructing a Graph, as we treat a constant
+-- and its negation as distinct nodes.
+getNegatedIds :: [Objective] -> Map ObjectiveLabel Objective
+getNegatedIds objs =
+  M.fromList $ mapMaybe f allConstants
+ where
+  objectivesById = getObjectivesById objs
+
+  allPrereqExpressions = mapMaybe _objectivePrerequisite objs
+  allConstants =
+    mapMaybe onlyNegative
+      . Set.toList
+      . Set.unions
+      . map (getDistinctConstants . logic)
+      $ allPrereqExpressions
+
+  f = sequenceA . \x -> (x, M.lookup x objectivesById)
+
+  onlyNegative = \case
+    BE.Negative x -> Just x
+    _ -> Nothing
+
+getObjectivesById :: [Objective] -> Map ObjectiveLabel Objective
+getObjectivesById objs =
+  M.fromList $
+    map swap $
+      mapMaybe (sequenceA . (id &&& _objectiveId)) objs
+
+-- | Uses the textual labels for those objectives that
+-- have them, and assigns arbitrary integer IDs for
+-- the remaining.
+--
+-- Only necessary for constructing a "Graph".
+assignIds :: [Objective] -> Map ObjectiveId Objective
+assignIds objs =
+  unlabeledObjsMap <> labeledObjsMap
+ where
+  objectivesById = getObjectivesById objs
+
+  labeledObjsMap = M.mapKeys (Label . Positive) objectivesById
+
+  unlabeledObjs = filter (null . _objectiveId) objs
+  unlabeledObjsMap = M.fromList $ zipWith (\x y -> (Ordinal x, y)) [0 ..] unlabeledObjs
+
+type Edges = [(Objective, ObjectiveId, [ObjectiveId])]
+
+-- | NOTE: Based strictly on the goal labels, the graph could
+-- potentially contain a cycle, if there exist
+-- mutually-exclusive goals. That is, if goal A depends on the NOT
+-- of "goal B".  Goal B could then also depend on "NOT Goal A" (re-enforcing the
+-- mutual-exclusivity), or it could mandate a completion order, e.g.:
+-- Goal A and Goal B are simultaneously available to pursue.  However, if the
+-- player completes Goal B first, then it closes off the option to complete
+-- Goal A.  However, if Goal A is completed first, then the user is also allowed
+-- to complete Goal B.
+--
+-- To avoid a "cycle" in this circumstance, "A" needs to exist as a distinct node
+-- from "NOT A" in the graph.
+makeGraph :: Edges -> Graph
+makeGraph edges =
+  myGraph
+ where
+  (myGraph, _, _) = graphFromEdges edges
+
+makeGraphEdges :: [Objective] -> Edges
+makeGraphEdges objectives =
+  rootTuples <> negatedTuples
+ where
+  rootTuples = map f $ M.toList $ assignIds objectives
+  negatedTuples = map gg $ M.toList $ getNegatedIds objectives
+  gg (k, v) = (v, Label $ BE.Negative k, [])
+
+  f (k, v) = (v, k, maybe [] (map Label . g) $ _objectivePrerequisite v)
+  g = Set.toList . getDistinctConstants . logic
+
+isAcyclicGraph :: [SCC Objective] -> Bool
+isAcyclicGraph =
+  all isAcyclicVertex
+ where
+  isAcyclicVertex = \case
+    AcyclicSCC _ -> True
+    _ -> False
+
+makeGraphInfo :: ObjectiveCompletion -> GraphInfo
+makeGraphInfo oc =
+  GraphInfo
+    (makeGraph edges)
+    (isAcyclicGraph connectedComponents)
+    connectedComponents
+    (M.keys $ assignIds objs)
+ where
+  edges = makeGraphEdges objs
+  connectedComponents = stronglyConnComp edges
+  objs = listAllObjectives $ completionBuckets oc
diff --git a/src/Swarm/Game/Scenario/Objective/Logic.hs b/src/Swarm/Game/Scenario/Objective/Logic.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/Objective/Logic.hs
@@ -0,0 +1,46 @@
+-- | A model for defining boolean expressions for Objective prerequisites.
+--
+-- This model is intended to be user-facing in the .yaml files, and is
+-- distinct from that in 'Data.BoolExpr'.
+module Swarm.Game.Scenario.Objective.Logic where
+
+import Control.Applicative ((<|>))
+import Data.Aeson
+import Data.BoolExpr
+import Data.Char (toLower)
+import Data.List.NonEmpty (NonEmpty)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+type ObjectiveLabel = Text
+
+-- | In contrast with the "BoolExpr" type,
+-- "And" and "Or" can have /one or more/ children
+-- instead of /exactly two/.
+data Prerequisite a
+  = And (NonEmpty (Prerequisite a))
+  | Or (NonEmpty (Prerequisite a))
+  | Not (Prerequisite a)
+  | Id a
+  deriving (Eq, Show, Generic, Functor, Foldable)
+
+prerequisiteOptions :: Options
+prerequisiteOptions =
+  defaultOptions
+    { sumEncoding = ObjectWithSingleField
+    , constructorTagModifier = map toLower
+    }
+
+instance ToJSON (Prerequisite ObjectiveLabel) where
+  toJSON = genericToJSON prerequisiteOptions
+
+instance FromJSON (Prerequisite ObjectiveLabel) where
+  parseJSON x = preString x <|> genericParseJSON prerequisiteOptions x
+   where
+    preString = withText "prerequisite" $ pure . Id
+
+toBoolExpr :: Prerequisite a -> BoolExpr a
+toBoolExpr (And xs) = foldr1 BAnd (fmap toBoolExpr xs)
+toBoolExpr (Or xs) = foldr1 BOr (fmap toBoolExpr xs)
+toBoolExpr (Not x) = BNot $ toBoolExpr x
+toBoolExpr (Id x) = BConst $ pure x
diff --git a/src/Swarm/Game/Scenario/Objective/Presentation/Model.hs b/src/Swarm/Game/Scenario/Objective/Presentation/Model.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/Objective/Presentation/Model.hs
@@ -0,0 +1,117 @@
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE NoGeneralizedNewtypeDeriving #-}
+
+-- A UI-centric model for Objective presentation.
+module Swarm.Game.Scenario.Objective.Presentation.Model where
+
+import Brick.Focus
+import Brick.Widgets.List qualified as BL
+import Control.Lens (makeLenses)
+import Data.Aeson
+import Data.List.NonEmpty (NonEmpty, nonEmpty)
+import Data.List.NonEmpty qualified as NE
+import Data.Map (Map)
+import Data.Map qualified as M
+import Data.Maybe (mapMaybe)
+import GHC.Generics (Generic)
+import Swarm.Game.Scenario.Objective
+import Swarm.Game.Scenario.Objective.WinCheck
+import Swarm.TUI.Model.Name
+import Swarm.Util (listEnums)
+
+-- | These are intended to be used as keys in a map
+-- of lists of goals.
+data GoalStatus
+  = -- | Goals in this category have other goals as prerequisites.
+    -- However, they are only displayed if the "previewable" attribute
+    -- is `true`.
+    Upcoming
+  | -- | Goals in this category may be pursued in parallel.
+    -- However, they are only displayed if the "hidden" attribute
+    -- is `false`.
+    Active
+  | -- | A goal's programmatic condition, as well as all its prerequisites, were completed.
+    -- This is a "latch" mechanism; at some point the conditions required to meet the goal may
+    -- no longer hold. Nonetheless, the goal remains "completed".
+    Completed
+  | -- | A goal that can no longer be achieved.
+    -- If this goal is not an "optional" goal, then the player
+    -- also "Loses" the scenario.
+    --
+    -- Note that currently the only way to "Fail" a goal is by way
+    -- of a negative prerequisite that was completed.
+    Failed
+  deriving (Show, Eq, Ord, Bounded, Enum, Generic, ToJSON, ToJSONKey)
+
+-- | TODO: #1044 Could also add an "ObjectiveFailed" constructor...
+newtype Announcement
+  = ObjectiveCompleted Objective
+  deriving (Show, Generic, ToJSON)
+
+type CategorizedGoals = Map GoalStatus (NonEmpty Objective)
+
+data GoalEntry
+  = Header GoalStatus
+  | Goal GoalStatus Objective
+
+isHeader :: GoalEntry -> Bool
+isHeader = \case
+  Header _ -> True
+  _ -> False
+
+data GoalTracking = GoalTracking
+  { announcements :: [Announcement]
+  -- ^ TODO: #1044 the actual contents of these are not used yet,
+  -- other than as a flag to pop up the Goal dialog.
+  , goals :: CategorizedGoals
+  }
+  deriving (Generic, ToJSON)
+
+data GoalDisplay = GoalDisplay
+  { _goalsContent :: GoalTracking
+  , _listWidget :: BL.List Name GoalEntry
+  -- ^ required for maintaining the selection/navigation
+  -- state among list items
+  , _focus :: FocusRing Name
+  }
+
+makeLenses ''GoalDisplay
+
+emptyGoalDisplay :: GoalDisplay
+emptyGoalDisplay =
+  GoalDisplay
+    (GoalTracking mempty mempty)
+    (BL.list (GoalWidgets ObjectivesList) mempty 1)
+    (focusRing $ map GoalWidgets listEnums)
+
+hasAnythingToShow :: GoalTracking -> Bool
+hasAnythingToShow (GoalTracking ann g) = not (null ann && null g)
+
+hasMultipleGoals :: GoalTracking -> Bool
+hasMultipleGoals gt =
+  goalCount > 1
+ where
+  goalCount = sum . M.elems . M.map NE.length . goals $ gt
+
+constructGoalMap :: Bool -> ObjectiveCompletion -> CategorizedGoals
+constructGoalMap isCheating objectiveCompletion@(ObjectiveCompletion buckets _) =
+  M.fromList $
+    mapMaybe (traverse nonEmpty) categoryList
+ where
+  categoryList =
+    [ (Upcoming, displayableInactives)
+    , (Active, suppressHidden activeGoals)
+    , (Completed, completed buckets)
+    , (Failed, unwinnable buckets)
+    ]
+
+  displayableInactives =
+    suppressHidden $
+      filter (maybe False previewable . _objectivePrerequisite) inactiveGoals
+
+  suppressHidden =
+    if isCheating
+      then id
+      else filter $ not . _objectiveHidden
+
+  (activeGoals, inactiveGoals) = partitionActiveObjectives objectiveCompletion
diff --git a/src/Swarm/Game/Scenario/Objective/Presentation/Render.hs b/src/Swarm/Game/Scenario/Objective/Presentation/Render.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/Objective/Presentation/Render.hs
@@ -0,0 +1,91 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- Display logic for Objectives.
+module Swarm.Game.Scenario.Objective.Presentation.Render where
+
+import Brick hiding (Direction, Location)
+import Brick.Focus
+import Brick.Widgets.Center
+import Brick.Widgets.List qualified as BL
+import Control.Applicative ((<|>))
+import Control.Lens hiding (Const, from)
+import Data.List.NonEmpty qualified as NE
+import Data.Map.Strict qualified as M
+import Data.Maybe (listToMaybe)
+import Data.Vector qualified as V
+import Swarm.Game.Scenario.Objective
+import Swarm.Game.Scenario.Objective.Presentation.Model
+import Swarm.TUI.Attr
+import Swarm.TUI.Model.Name
+import Swarm.TUI.View.Util
+
+makeListWidget :: GoalTracking -> BL.List Name GoalEntry
+makeListWidget (GoalTracking _announcements categorizedObjs) =
+  BL.listMoveTo 1 $ BL.list (GoalWidgets ObjectivesList) (V.fromList objList) 1
+ where
+  objList = concatMap f $ M.toList categorizedObjs
+  f (h, xs) = Header h : map (Goal h) (NE.toList xs)
+
+renderGoalsDisplay :: GoalDisplay -> Widget Name
+renderGoalsDisplay gd =
+  if hasMultiple
+    then
+      hBox
+        [ leftSide
+        , hLimitPercent 70 $ padLeft (Pad 2) goalElaboration
+        ]
+    else goalElaboration
+ where
+  hasMultiple = hasMultipleGoals $ gd ^. goalsContent
+  lw = _listWidget gd
+  fr = _focus gd
+  leftSide =
+    hLimitPercent 30 $
+      padAll 1 $
+        vBox
+          [ hCenter $ str "Goals"
+          , padAll 1 $
+              vLimit 10 $
+                withFocusRing fr (BL.renderList drawGoalListItem) lw
+          ]
+
+  -- Adds very subtle coloring to indicate focus switch
+  highlightIfFocused = case (hasMultiple, focusGetCurrent fr) of
+    (True, Just (GoalWidgets GoalSummary)) -> withAttr lightCyanAttr
+    _ -> id
+
+  -- Note: An extra "padRight" is inserted to account for the vertical scrollbar,
+  -- whether or not it appears.
+  goalElaboration =
+    clickable (GoalWidgets GoalSummary) $
+      maybeScroll ModalViewport $
+        maybe emptyWidget (padAll 1 . padRight (Pad 1) . highlightIfFocused . singleGoalDetails . snd) $
+          BL.listSelectedElement lw
+
+getCompletionIcon :: Objective -> GoalStatus -> Widget Name
+getCompletionIcon obj = \case
+  Upcoming -> withAttr yellowAttr $ txt " ○  "
+  Active -> withAttr cyanAttr $ txt " ○  "
+  Failed -> withAttr redAttr $ txt " ●  "
+  Completed -> withAttr colorAttr $ txt " ●  "
+   where
+    colorAttr =
+      if obj ^. objectiveHidden
+        then magentaAttr
+        else greenAttr
+
+drawGoalListItem ::
+  Bool ->
+  GoalEntry ->
+  Widget Name
+drawGoalListItem _isSelected e = case e of
+  Header gs -> withAttr boldAttr $ str $ show gs
+  Goal gs obj -> getCompletionIcon obj gs <+> titleWidget
+   where
+    textSource = obj ^. objectiveTeaser <|> obj ^. objectiveId <|> listToMaybe (obj ^. objectiveGoal)
+    titleWidget = maybe (txt "?") withEllipsis textSource
+
+singleGoalDetails :: GoalEntry -> Widget Name
+singleGoalDetails = \case
+  Header _gs -> displayParagraphs [" "]
+  Goal _gs obj -> displayParagraphs $ obj ^. objectiveGoal
diff --git a/src/Swarm/Game/Scenario/Objective/Validation.hs b/src/Swarm/Game/Scenario/Objective/Validation.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/Objective/Validation.hs
@@ -0,0 +1,50 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Validity checking for Objective prerequisites
+module Swarm.Game.Scenario.Objective.Validation where
+
+import Control.Monad (unless)
+import Data.Foldable (for_, toList)
+import Data.Graph (stronglyConnComp)
+import Data.Maybe (mapMaybe)
+import Data.Set qualified as Set
+import Data.Text qualified as T
+import Swarm.Game.Scenario.Objective
+import Swarm.Game.Scenario.Objective.Graph
+import Swarm.Util (quote)
+import Witch (into)
+
+-- | Performs monadic validation before returning
+-- the "pure" construction of a wrapper record.
+-- This validation entails:
+-- 1) Ensuring that all goal references utilized in prerequisites
+--    actually exist
+-- 2) Ensuring that the graph of dependencies is acyclic.
+validateObjectives ::
+  MonadFail m =>
+  [Objective] ->
+  m [Objective]
+validateObjectives objectives = do
+  for_ objectives $ \x -> case _objectivePrerequisite x of
+    Just p ->
+      unless (null remaining) $
+        fail . into @String $
+          T.unwords
+            [ "Reference to undefined objective(s)"
+            , T.intercalate ", " (map quote $ Set.toList remaining) <> "."
+            , "Defined are:"
+            , T.intercalate ", " (map quote $ Set.toList allIds)
+            ]
+     where
+      refs = Set.fromList $ toList $ logic p
+      remaining = Set.difference refs allIds
+    Nothing -> return ()
+
+  unless (isAcyclicGraph connectedComponents) $
+    fail . into @String $
+      T.unwords ["There are dependency cycles in the prerequisites."]
+
+  return objectives
+ where
+  connectedComponents = stronglyConnComp $ makeGraphEdges objectives
+  allIds = Set.fromList $ mapMaybe _objectiveId objectives
diff --git a/src/Swarm/Game/Scenario/Objective/WinCheck.hs b/src/Swarm/Game/Scenario/Objective/WinCheck.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/Objective/WinCheck.hs
@@ -0,0 +1,83 @@
+-- | Utilities to check whether conditions are met for a game win/loss.
+module Swarm.Game.Scenario.Objective.WinCheck where
+
+import Data.Aeson
+import Data.BoolExpr qualified as BE
+import Data.BoolExpr.Simplify qualified as Simplify
+import Data.List (partition)
+import Data.Map qualified as M
+import Data.Set (Set)
+import Data.Set qualified as Set
+import GHC.Generics (Generic)
+import Swarm.Game.Scenario.Objective
+import Swarm.Game.Scenario.Objective.Logic as L
+
+-- | We have "won" if all of the "unwinnable" or remaining "incomplete" objectives are "optional".
+didWin :: ObjectiveCompletion -> Bool
+didWin oc = all _objectiveOptional $ incomplete buckets <> unwinnable buckets
+ where
+  buckets = completionBuckets oc
+
+-- | We have "lost" if any of the "unwinnable" objectives not "optional".
+didLose :: ObjectiveCompletion -> Bool
+didLose oc = not $ all _objectiveOptional $ unwinnable buckets
+ where
+  buckets = completionBuckets oc
+
+isPrereqsSatisfied :: ObjectiveCompletion -> Objective -> Bool
+isPrereqsSatisfied completions =
+  maybe True f . _objectivePrerequisite
+ where
+  f = BE.evalBoolExpr getTruth . L.toBoolExpr . logic
+
+  getTruth :: ObjectiveLabel -> Bool
+  getTruth label = Set.member label $ completedIDs completions
+
+isUnwinnablePrereq :: Set ObjectiveLabel -> Prerequisite ObjectiveLabel -> Bool
+isUnwinnablePrereq completedObjectives =
+  Simplify.cannotBeTrue . Simplify.replace boolMap . L.toBoolExpr
+ where
+  boolMap =
+    M.fromList $
+      map (,True) $
+        Set.toList completedObjectives
+
+isUnwinnable :: ObjectiveCompletion -> Objective -> Bool
+isUnwinnable completions obj =
+  maybe False (isUnwinnablePrereq (completedIDs completions) . logic) $ _objectivePrerequisite obj
+
+-- | The first element of the returned tuple consists of "active" objectives,
+-- the second element "inactive".
+partitionActiveObjectives :: ObjectiveCompletion -> ([Objective], [Objective])
+partitionActiveObjectives oc =
+  partition (isPrereqsSatisfied oc) $
+    incomplete $
+      completionBuckets oc
+
+getActiveObjectives :: ObjectiveCompletion -> [Objective]
+getActiveObjectives =
+  fst . partitionActiveObjectives
+
+-- | For debugging only (via Web API)
+data PrereqSatisfaction = PrereqSatisfaction
+  { objective :: Objective
+  , deps :: Set (BE.Signed ObjectiveLabel)
+  , prereqsSatisfied :: Bool
+  }
+  deriving (Generic, ToJSON)
+
+-- | Used only by the web interface for debugging
+getSatisfaction :: ObjectiveCompletion -> [PrereqSatisfaction]
+getSatisfaction oc =
+  map f $
+    listAllObjectives $
+      completionBuckets oc
+ where
+  f y =
+    PrereqSatisfaction
+      y
+      (maybe mempty (getDistinctConstants . logic) $ _objectivePrerequisite y)
+      (isPrereqsSatisfied oc y)
+
+getDistinctConstants :: (Ord a) => Prerequisite a -> Set (BE.Signed a)
+getDistinctConstants = Set.fromList . BE.constants . toBoolExpr
diff --git a/src/Swarm/Game/Scenario/RobotLookup.hs b/src/Swarm/Game/Scenario/RobotLookup.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/RobotLookup.hs
@@ -0,0 +1,51 @@
+{-# LANGUAGE DerivingVia #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Swarm.Game.Scenario.RobotLookup where
+
+import Control.Lens hiding (from, (<.>))
+import Data.Map (Map)
+import Data.Map qualified as M
+import Data.Text (Text)
+import Swarm.Game.Entity
+import Swarm.Game.Robot (TRobot, trobotName)
+import Swarm.Util.Yaml
+
+------------------------------------------------------------
+-- Robot map
+------------------------------------------------------------
+
+-- | A robot template paired with its definition's index within
+-- the Scenario file
+type IndexedTRobot = (Int, TRobot)
+
+-- | A map from names to robots, used to look up robots in scenario
+--   descriptions.
+type RobotMap = Map Text IndexedTRobot
+
+-- | Create a 'RobotMap' from a list of robot templates.
+buildRobotMap :: [TRobot] -> RobotMap
+buildRobotMap rs = M.fromList $ zipWith (\x y -> (view trobotName y, (x, y))) [0 ..] rs
+
+------------------------------------------------------------
+-- Lookup utilities
+------------------------------------------------------------
+
+-- | Look up a thing by name, throwing a parse error if it is not
+--   found.
+getThing :: String -> (Text -> m -> Maybe a) -> Text -> ParserE m a
+getThing thing lkup name = do
+  m <- getE
+  case lkup name m of
+    Nothing -> fail $ "Unknown " <> thing <> " name: " ++ show name
+    Just a -> return a
+
+-- | Look up an entity by name in an 'EntityMap', throwing a parse
+--   error if it is not found.
+getEntity :: Text -> ParserE EntityMap Entity
+getEntity = getThing "entity" lookupEntityName
+
+-- | Look up a robot by name in a 'RobotMap', throwing a parse error
+--   if it is not found.
+getRobot :: Text -> ParserE RobotMap IndexedTRobot
+getRobot = getThing "robot" M.lookup
diff --git a/src/Swarm/Game/Scenario/Style.hs b/src/Swarm/Game/Scenario/Style.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/Style.hs
@@ -0,0 +1,37 @@
+module Swarm.Game.Scenario.Style where
+
+import Data.Aeson
+import Data.Set (Set)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+
+data StyleFlag
+  = Standout
+  | Italic
+  | Strikethrough
+  | Underline
+  | ReverseVideo
+  | Blink
+  | Dim
+  | Bold
+  deriving (Eq, Ord, Show, Generic)
+
+styleFlagJsonOptions :: Options
+styleFlagJsonOptions =
+  defaultOptions
+    { sumEncoding = UntaggedValue
+    }
+
+instance FromJSON StyleFlag where
+  parseJSON = genericParseJSON styleFlagJsonOptions
+
+newtype HexColor = HexColor Text
+  deriving (Eq, Show, Generic, FromJSON)
+
+data CustomAttr = CustomAttr
+  { name :: String
+  , fg :: Maybe HexColor
+  , bg :: Maybe HexColor
+  , style :: Maybe (Set StyleFlag)
+  }
+  deriving (Eq, Show, Generic, FromJSON)
diff --git a/src/Swarm/Game/Scenario/WorldDescription.hs b/src/Swarm/Game/Scenario/WorldDescription.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Game/Scenario/WorldDescription.hs
@@ -0,0 +1,64 @@
+{-# LANGUAGE DerivingVia #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+module Swarm.Game.Scenario.WorldDescription where
+
+import Data.Aeson.Key qualified as Key
+import Data.Aeson.KeyMap (KeyMap)
+import Data.Aeson.KeyMap qualified as KeyMap
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Yaml as Y
+import Swarm.Game.Entity
+import Swarm.Game.Scenario.Cell
+import Swarm.Game.Scenario.RobotLookup
+import Swarm.Util.Location
+import Swarm.Util.Yaml
+import Witch (into)
+
+------------------------------------------------------------
+-- World description
+------------------------------------------------------------
+
+-- | A world palette maps characters to 'Cell' values.
+newtype WorldPalette e = WorldPalette
+  {unPalette :: KeyMap (PCell e)}
+  deriving (Eq, Show)
+
+instance FromJSONE (EntityMap, RobotMap) (WorldPalette Entity) where
+  parseJSONE = withObjectE "palette" $ fmap WorldPalette . mapM parseJSONE
+
+-- | A description of a world parsed from a YAML file.
+-- This type is parameterized to accommodate Cells that
+-- utilize a less stateful Entity type.
+data PWorldDescription e = WorldDescription
+  { defaultTerrain :: Maybe (PCell e)
+  , offsetOrigin :: Bool
+  , palette :: WorldPalette e
+  , ul :: Location
+  , area :: [[PCell e]]
+  }
+  deriving (Eq, Show)
+
+type WorldDescription = PWorldDescription Entity
+
+instance FromJSONE (EntityMap, RobotMap) WorldDescription where
+  parseJSONE = withObjectE "world description" $ \v -> do
+    pal <- v ..:? "palette" ..!= WorldPalette mempty
+    WorldDescription
+      <$> v ..:? "default"
+      <*> liftE (v .:? "offset" .!= False)
+      <*> pure pal
+      <*> liftE (v .:? "upperleft" .!= origin)
+      <*> liftE ((v .:? "map" .!= "") >>= paintMap pal)
+
+-- | "Paint" a world map using a 'WorldPalette', turning it from a raw
+--   string into a nested list of 'Cell' values by looking up each
+--   character in the palette, failing if any character in the raw map
+--   is not contained in the palette.
+paintMap :: MonadFail m => WorldPalette e -> Text -> m [[PCell e]]
+paintMap pal = traverse (traverse toCell . into @String) . T.lines
+ where
+  toCell c = case KeyMap.lookup (Key.fromString [c]) (unPalette pal) of
+    Nothing -> fail $ "Char not in world palette: " ++ show c
+    Just cell -> return cell
diff --git a/src/Swarm/Game/ScenarioInfo.hs b/src/Swarm/Game/ScenarioInfo.hs
--- a/src/Swarm/Game/ScenarioInfo.hs
+++ b/src/Swarm/Game/ScenarioInfo.hs
@@ -49,7 +49,7 @@
 import Control.Carrier.Lift (Lift, sendIO)
 import Control.Carrier.Throw.Either (Throw, runThrow, throwError)
 import Control.Lens hiding (from, (<.>))
-import Control.Monad (unless, when)
+import Control.Monad (filterM, unless, when)
 import Data.Aeson (
   Options (..),
   defaultOptions,
@@ -59,7 +59,7 @@
  )
 import Data.Char (isSpace, toLower)
 import Data.Function (on)
-import Data.List (intercalate, stripPrefix, (\\))
+import Data.List (intercalate, isPrefixOf, stripPrefix, (\\))
 import Data.Map (Map)
 import Data.Map qualified as M
 import Data.Maybe (isJust)
@@ -90,20 +90,20 @@
 data ScenarioStatus
   = NotStarted
   | InProgress
-      { -- | Time when the scenario was started including time zone.
-        _scenarioStarted :: ZonedTime
-      , -- | Time elapsed until quitting the scenario.
-        _scenarioElapsed :: NominalDiffTime
-      , -- | Ticks elapsed until quitting the scenario.
-        _scenarioElapsedTicks :: Integer
+      { _scenarioStarted :: ZonedTime
+      -- ^ Time when the scenario was started including time zone.
+      , _scenarioElapsed :: NominalDiffTime
+      -- ^ Time elapsed until quitting the scenario.
+      , _scenarioElapsedTicks :: Integer
+      -- ^ Ticks elapsed until quitting the scenario.
       }
   | Complete
-      { -- | Time when the scenario was started including time zone.
-        _scenarioStarted :: ZonedTime
-      , -- | Time elapsed until quitting the scenario.
-        _scenarioElapsed :: NominalDiffTime
-      , -- | Ticks elapsed until quitting the scenario.
-        _scenarioElapsedTicks :: Integer
+      { _scenarioStarted :: ZonedTime
+      -- ^ Time when the scenario was started including time zone.
+      , _scenarioElapsed :: NominalDiffTime
+      -- ^ Time elapsed until quitting the scenario.
+      , _scenarioElapsedTicks :: Integer
+      -- ^ Ticks elapsed until quitting the scenario.
       }
   deriving (Eq, Ord, Show, Read, Generic)
 
@@ -256,11 +256,14 @@
     False -> do
       when (dirName /= "Testing") $
         sendIO . putStrLn $
-          "Warning: no " <> orderFileName <> " file found in " <> dirName
+          "Warning: no "
+            <> orderFileName
+            <> " file found in "
+            <> dirName
             <> ", using alphabetical order"
       return Nothing
     True -> Just . filter (not . null) . lines <$> sendIO (readFile orderFile)
-  fs <- sendIO $ keepYamlOrDirectory <$> listDirectory dir
+  fs <- sendIO $ keepYamlOrPublicDirectory dir =<< listDirectory dir
 
   case morder of
     Just order -> do
@@ -269,25 +272,38 @@
 
       unless (null missing) $
         sendIO . putStr . unlines $
-          ( "Warning: while processing " <> (dirName </> orderFileName) <> ": files not listed in "
+          ( "Warning: while processing "
+              <> (dirName </> orderFileName)
+              <> ": files not listed in "
               <> orderFileName
               <> " will be ignored"
-          ) :
-          map ("  - " <>) missing
+          )
+            : map ("  - " <>) missing
 
       unless (null dangling) $
         sendIO . putStr . unlines $
-          ( "Warning: while processing " <> (dirName </> orderFileName)
+          ( "Warning: while processing "
+              <> (dirName </> orderFileName)
               <> ": nonexistent files will be ignored"
-          ) :
-          map ("  - " <>) dangling
+          )
+            : map ("  - " <>) dangling
     Nothing -> pure ()
 
   -- Only keep the files from 00-ORDER.txt that actually exist.
   let morder' = filter (`elem` fs) <$> morder
   SC morder' . M.fromList <$> mapM (\item -> (item,) <$> loadScenarioItem em (dir </> item)) fs
  where
-  keepYamlOrDirectory = filter (\f -> takeExtensions f `elem` ["", ".yaml"])
+  -- Keep only files which are .yaml files or directories that start
+  -- with something other than an underscore.
+  keepYamlOrPublicDirectory = filterM . isCatalogEntry
+
+  -- Whether the directory or file should be included in the scenario catalog.
+  isCatalogEntry d f = do
+    isDir <- doesDirectoryExist $ d </> f
+    return $
+      if isDir
+        then not $ "_" `isPrefixOf` f
+        else takeExtensions f == ".yaml"
 
 -- | How to transform scenario path to save path.
 scenarioPathToSavePath :: FilePath -> FilePath -> FilePath
diff --git a/src/Swarm/Game/State.hs b/src/Swarm/Game/State.hs
--- a/src/Swarm/Game/State.hs
+++ b/src/Swarm/Game/State.hs
@@ -1,4 +1,3 @@
-{-# LANGUAGE DeriveAnyClass #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
 {-# LANGUAGE RecordWildCards #-}
@@ -20,10 +19,12 @@
   -- * Game state record and related types
   ViewCenterRule (..),
   REPLStatus (..),
+  WinStatus (..),
   WinCondition (..),
+  ObjectiveCompletion (..),
   _NoWinCondition,
   _WinConditions,
-  _Won,
+  Announcement (..),
   RunStatus (..),
   Seed,
   GameState,
@@ -32,6 +33,8 @@
   creativeMode,
   winCondition,
   winSolution,
+  gameAchievements,
+  announcementQueue,
   runStatus,
   paused,
   robotMap,
@@ -114,18 +117,17 @@
 import Data.Array (Array, listArray)
 import Data.Bifunctor (first)
 import Data.Foldable (toList)
-import Data.Int (Int64)
+import Data.Int (Int32)
 import Data.IntMap (IntMap)
 import Data.IntMap qualified as IM
 import Data.IntSet (IntSet)
 import Data.IntSet qualified as IS
 import Data.IntSet.Lens (setOf)
 import Data.List (partition, sortOn)
-import Data.List.NonEmpty (NonEmpty)
 import Data.List.NonEmpty qualified as NE
 import Data.Map (Map)
 import Data.Map qualified as M
-import Data.Maybe (fromMaybe, isJust, mapMaybe)
+import Data.Maybe (fromMaybe, isJust, isNothing, mapMaybe)
 import Data.Sequence (Seq ((:<|)))
 import Data.Sequence qualified as Seq
 import Data.Set qualified as S
@@ -134,8 +136,7 @@
 import Data.Text.IO qualified as T (readFile)
 import Data.Time (getZonedTime)
 import GHC.Generics (Generic)
-import Linear
-import Swarm.Game.CESK (emptyStore, initMachine)
+import Swarm.Game.CESK (emptyStore, finalValue, initMachine)
 import Swarm.Game.Entity
 import Swarm.Game.Recipe (
   Recipe,
@@ -145,9 +146,10 @@
   reqRecipeMap,
  )
 import Swarm.Game.Robot
+import Swarm.Game.Scenario.Objective
+import Swarm.Game.Scenario.Objective.Presentation.Model
 import Swarm.Game.ScenarioInfo
 import Swarm.Game.Terrain (TerrainType (..))
-import Swarm.Game.Value (Value)
 import Swarm.Game.World (Coords (..), WorldFun (..), locToCoords, worldFunFromArray)
 import Swarm.Game.World qualified as W
 import Swarm.Game.WorldGen (Seed, findGoodOrigin, testWorld2FromArray)
@@ -155,10 +157,14 @@
 import Swarm.Language.Context qualified as Ctx
 import Swarm.Language.Pipeline (ProcessedTerm)
 import Swarm.Language.Pipeline.QQ (tmQ)
-import Swarm.Language.Syntax (Const, Term (TText), allConst)
+import Swarm.Language.Syntax (Const, Term' (TText), allConst)
 import Swarm.Language.Typed (Typed (Typed))
 import Swarm.Language.Types
+import Swarm.Language.Value (Value)
+import Swarm.TUI.Model.Achievement.Attainment
+import Swarm.TUI.Model.Achievement.Definitions
 import Swarm.Util (getDataFileNameSafe, getElemsInArea, isRightOr, manhattan, uniq, (<+=), (<<.=), (?))
+import Swarm.Util.Location
 import System.Clock qualified as Clock
 import System.Random (StdGen, mkStdGen, randomRIO)
 import Witch (into)
@@ -175,7 +181,7 @@
 --   world viewport.
 data ViewCenterRule
   = -- | The view should be centered on an absolute position.
-    VCLocation (V2 Int64)
+    VCLocation Location
   | -- | The view should be centered on a certain robot.
     VCRobot RID
   deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)
@@ -186,6 +192,7 @@
 data REPLStatus
   = -- | The REPL is not doing anything actively at the moment.
     --   We persist the last value and its type though.
+    --   INVARIANT: the Value stored here is not a VResult.
     REPLDone (Maybe (Typed Value))
   | -- | A command entered at the REPL is currently being run.  The
     --   'Polytype' represents the type of the expression that was
@@ -194,15 +201,28 @@
     REPLWorking (Typed (Maybe Value))
   deriving (Eq, Show, Generic, FromJSON, ToJSON)
 
+data WinStatus
+  = -- | There are one or more objectives remaining that the player
+    -- has not yet accomplished.
+    Ongoing
+  | -- | The player has won.
+    -- The boolean indicates whether they have
+    -- already been congratulated.
+    Won Bool
+  | -- | The player has completed certain "goals" that preclude
+    -- (via negative prerequisites) the completion of all of the
+    -- required goals.
+    -- The boolean indicates whether they have
+    -- already been informed.
+    Unwinnable Bool
+  deriving (Show, Generic, FromJSON, ToJSON)
+
 data WinCondition
   = -- | There is no winning condition.
     NoWinCondition
-  | -- | There are one or more objectives remaining that the player
-    --   has not yet accomplished.
-    WinConditions (NonEmpty Objective)
-  | -- | The player has won. The boolean indicates whether they have
-    --   already been congratulated.
-    Won Bool
+  | -- | NOTE: It is possible to continue to achieve "optional" objectives
+    -- even after the game has been won (or deemed unwinnable).
+    WinConditions WinStatus ObjectiveCompletion
   deriving (Show, Generic, FromJSON, ToJSON)
 
 makePrisms ''WinCondition
@@ -256,6 +276,8 @@
   { _creativeMode :: Bool
   , _winCondition :: WinCondition
   , _winSolution :: Maybe ProcessedTerm
+  , _gameAchievements :: Map GameplayAchievement Attainment
+  , _announcementQueue :: Seq Announcement
   , _runStatus :: RunStatus
   , _robotMap :: IntMap Robot
   , -- A set of robots to consider for the next game tick. It is guaranteed to
@@ -272,7 +294,7 @@
     -- Waiting robots for a given time are a list because it is cheaper to
     -- append to a list than to a Set.
     _waitingRobots :: Map Integer [RID]
-  , _robotsByLocation :: Map (V2 Int64) IntSet
+  , _robotsByLocation :: Map Location IntSet
   , _allDiscoveredEntities :: Inventory
   , _availableRecipes :: Notifications (Recipe Entity)
   , _availableCommands :: Notifications Const
@@ -290,7 +312,7 @@
   , _knownEntities :: [Text]
   , _world :: W.World Int Entity
   , _viewCenterRule :: ViewCenterRule
-  , _viewCenter :: V2 Int64
+  , _viewCenter :: Location
   , _needsRedraw :: Bool
   , _replStatus :: REPLStatus
   , _replNextValueIndex :: Integer
@@ -332,6 +354,16 @@
 --   and to show help to cheaters (or testers).
 winSolution :: Lens' GameState (Maybe ProcessedTerm)
 
+-- | Map of in-game achievements that were attained
+gameAchievements :: Lens' GameState (Map GameplayAchievement Attainment)
+
+-- | A queue of global announcments.
+-- Note that this is distinct from the "messageQueue",
+-- which is for messages emitted by robots.
+--
+-- Note that we put the newest entry to the right.
+announcementQueue :: Lens' GameState (Seq Announcement)
+
 -- | The current 'RunStatus'.
 runStatus :: Lens' GameState RunStatus
 
@@ -339,7 +371,7 @@
 paused :: Getter GameState Bool
 paused = to (\s -> s ^. runStatus /= Running)
 
--- | All the robots that currently exist in the game, indexed by name.
+-- | All the robots that currently exist in the game, indexed by ID.
 robotMap :: Lens' GameState (IntMap Robot)
 
 -- | The names of all robots that currently exist in the game, indexed by
@@ -351,10 +383,10 @@
 --   location of a robot changes, or a robot is created or destroyed.
 --   Fortunately, there are relatively few ways for these things to
 --   happen.
-robotsByLocation :: Lens' GameState (Map (V2 Int64) IntSet)
+robotsByLocation :: Lens' GameState (Map Location IntSet)
 
 -- | Get a list of all the robots at a particular location.
-robotsAtLocation :: V2 Int64 -> GameState -> [Robot]
+robotsAtLocation :: Location -> GameState -> [Robot]
 robotsAtLocation loc gs =
   mapMaybe (`IM.lookup` (gs ^. robotMap))
     . maybe [] IS.toList
@@ -362,8 +394,9 @@
     . view robotsByLocation
     $ gs
 
--- | Get robots in manhattan distastance from location.
-robotsInArea :: V2 Int64 -> Int64 -> GameState -> [Robot]
+-- | Get all the robots within a given Manhattan distance from a
+--   location.
+robotsInArea :: Location -> Int32 -> GameState -> [Robot]
 robotsInArea o d gs = map (rm IM.!) rids
  where
   rm = gs ^. robotMap
@@ -446,7 +479,7 @@
 --   modified directly, since it is calculated automatically from the
 --   'viewCenterRule'.  To modify the view center, either set the
 --   'viewCenterRule', or use 'modifyViewCenter'.
-viewCenter :: Getter GameState (V2 Int64)
+viewCenter :: Getter GameState Location
 viewCenter = to _viewCenter
 
 -- | Whether the world view needs to be redrawn.
@@ -549,14 +582,14 @@
 messageIsRecent :: GameState -> LogEntry -> Bool
 messageIsRecent gs e = e ^. leTime >= gs ^. ticks - 1
 
-messageIsFromNearby :: V2 Int64 -> LogEntry -> Bool
+messageIsFromNearby :: Location -> LogEntry -> Bool
 messageIsFromNearby l e = manhattan l (e ^. leLocation) <= hearingDistance
 
 -- | Given a current mapping from robot names to robots, apply a
 --   'ViewCenterRule' to derive the location it refers to.  The result
 --   is @Maybe@ because the rule may refer to a robot which does not
 --   exist.
-applyViewCenterRule :: ViewCenterRule -> IntMap Robot -> Maybe (V2 Int64)
+applyViewCenterRule :: ViewCenterRule -> IntMap Robot -> Maybe Location
 applyViewCenterRule (VCLocation l) _ = Just l
 applyViewCenterRule (VCRobot name) m = m ^? at name . _Just . robotLocation
 
@@ -579,7 +612,7 @@
 --   current value.  Note that this also modifies the 'viewCenterRule'
 --   to match.  After calling this function the 'viewCenterRule' will
 --   specify a particular location, not a robot.
-modifyViewCenter :: (V2 Int64 -> V2 Int64) -> GameState -> GameState
+modifyViewCenter :: (Location -> Location) -> GameState -> GameState
 modifyViewCenter update g =
   g
     & case g ^. viewCenterRule of
@@ -588,10 +621,10 @@
 
 -- | Given a width and height, compute the region, centered on the
 --   'viewCenter', that should currently be in view.
-viewingRegion :: GameState -> (Int64, Int64) -> (W.Coords, W.Coords)
+viewingRegion :: GameState -> (Int32, Int32) -> (W.Coords, W.Coords)
 viewingRegion g (w, h) = (W.Coords (rmin, cmin), W.Coords (rmax, cmax))
  where
-  V2 cx cy = g ^. viewCenter
+  Location cx cy = g ^. viewCenter
   (rmin, rmax) = over both (+ (-cy - h `div` 2)) (0, h - 1)
   (cmin, cmax) = over both (+ (cx - w `div` 2)) (0, w - 1)
 
@@ -682,7 +715,7 @@
 ------------------------------------------------------------
 
 -- | Create an initial game state record, first loading entities and
---   recipies from disk.
+--   recipes from disk.
 initGameState :: ExceptT Text IO GameState
 initGameState = do
   let guardRight what i = i `isRightOr` (\e -> "Failed to " <> what <> ": " <> e)
@@ -705,6 +738,10 @@
       { _creativeMode = False
       , _winCondition = NoWinCondition
       , _winSolution = Nothing
+      , -- This does not need to be initialized with anything,
+        -- since the master list of achievements is stored in UIState
+        _gameAchievements = mempty
+      , _announcementQueue = mempty
       , _runStatus = Running
       , _robotMap = IM.empty
       , _robotsByLocation = M.empty
@@ -727,7 +764,7 @@
       , _knownEntities = []
       , _world = W.emptyWorld (fromEnum StoneT)
       , _viewCenterRule = VCRobot 0
-      , _viewCenter = V2 0 0
+      , _viewCenter = origin
       , _needsRedraw = False
       , _replStatus = REPLDone Nothing
       , _replNextValueIndex = 0
@@ -775,13 +812,13 @@
       , _knownEntities = scenario ^. scenarioKnown
       , _world = theWorld theSeed
       , _viewCenterRule = VCRobot baseID
-      , _viewCenter = V2 0 0
+      , _viewCenter = origin
       , _needsRedraw = False
-      , -- When starting base with the run flag, REPL status must be set to working,
-        -- otherwise the store of definition cells is not saved (see #333)
-        _replStatus = case toRun of
-          Nothing -> REPLDone Nothing
-          Just _ -> REPLWorking (Typed Nothing PolyUnit mempty)
+      , -- When the base starts out running a program, the REPL status must be set to working,
+        -- otherwise the store of definition cells is not saved (see #333, #838)
+        _replStatus = case running of
+          False -> REPLDone Nothing
+          True -> REPLWorking (Typed Nothing PolyUnit mempty)
       , _replNextValueIndex = 0
       , _messageQueue = Empty
       , _focusedRobotID = baseID
@@ -829,6 +866,9 @@
     zipWith instantiateRobot [baseID ..] robotsByBasePrecedence
       -- If the  --run flag was used, use it to replace the CESK machine of the
       -- robot whose id is 0, i.e. the first robot listed in the scenario.
+      -- Note that this *replaces* any program the base robot otherwise
+      -- would have run (i.e. any program specified in the program: field
+      -- of the scenario description).
       & ix baseID . machine
         %~ case getCodeToRun <$> toRun of
           Nothing -> id
@@ -838,16 +878,20 @@
         %~ case scenario ^. scenarioCreative of
           False -> id
           True -> union (fromElems (map (0,) things))
-      & ix baseID . installedDevices
+      & ix baseID . equippedDevices
         %~ case scenario ^. scenarioCreative of
           False -> id
           True -> const (fromList devices)
 
+  running = case robotList of
+    [] -> False
+    (base : _) -> isNothing (finalValue (base ^. machine))
+
   -- Initial list of available commands = all commands enabled by
-  -- devices in inventory or installed; and commands that require no
+  -- devices in inventory or equipped; and commands that require no
   -- capability.
   allCapabilities r =
-    inventoryCapabilities (r ^. installedDevices)
+    inventoryCapabilities (r ^. equippedDevices)
       <> inventoryCapabilities (r ^. robotInventory)
   initialCaps = mconcat $ map allCapabilities robotList
   initialCommands =
@@ -857,7 +901,12 @@
 
   (genRobots, wf) = buildWorld em (scenario ^. scenarioWorld)
   theWorld = W.newWorld . wf
-  theWinCondition = maybe NoWinCondition WinConditions (NE.nonEmpty (scenario ^. scenarioObjectives))
+  theWinCondition =
+    maybe
+      NoWinCondition
+      (\x -> WinConditions Ongoing (ObjectiveCompletion (CompletionBuckets (NE.toList x) mempty mempty) mempty))
+      (NE.nonEmpty (scenario ^. scenarioObjectives))
+
   initGensym = length robotList - 1
   addRecipesWith f gRs = IM.unionWith (<>) (f $ scenario ^. scenarioRecipes) (g ^. gRs)
 
@@ -873,7 +922,7 @@
   worldGrid :: [[(TerrainType, Maybe Entity)]]
   worldGrid = (map . map) (cellTerrain &&& cellEntity) area
 
-  worldArray :: Array (Int64, Int64) (TerrainType, Maybe Entity)
+  worldArray :: Array (Int32, Int32) (TerrainType, Maybe Entity)
   worldArray = listArray ((ulr, ulc), (ulr + rs - 1, ulc + cs - 1)) (concat worldGrid)
 
   wf = case defaultTerrain of
diff --git a/src/Swarm/Game/Step.hs b/src/Swarm/Game/Step.hs
--- a/src/Swarm/Game/Step.hs
+++ b/src/Swarm/Game/Step.hs
@@ -18,37 +18,38 @@
 module Swarm.Game.Step where
 
 import Control.Applicative (liftA2)
+import Control.Arrow ((&&&))
 import Control.Carrier.Error.Either (runError)
 import Control.Carrier.State.Lazy
 import Control.Carrier.Throw.Either (ThrowC, runThrow)
 import Control.Effect.Error
 import Control.Effect.Lens
 import Control.Effect.Lift
-import Control.Lens as Lens hiding (Const, from, parts, use, uses, view, (%=), (+=), (.=), (<+=), (<>=))
-import Control.Monad (forM, forM_, guard, msum, unless, when)
+import Control.Lens as Lens hiding (Const, distrib, from, parts, use, uses, view, (%=), (+=), (.=), (<+=), (<>=))
+import Control.Monad (foldM, forM, forM_, guard, msum, unless, when)
 import Data.Array (bounds, (!))
 import Data.Bifunctor (second)
 import Data.Bool (bool)
-import Data.Containers.ListUtils (nubOrd)
+import Data.Char (chr, ord)
 import Data.Either (partitionEithers, rights)
 import Data.Foldable (asum, traverse_)
 import Data.Functor (void)
-import Data.Int (Int64)
 import Data.IntMap qualified as IM
 import Data.IntSet qualified as IS
-import Data.List (find)
+import Data.List (find, sortOn)
 import Data.List qualified as L
-import Data.List.NonEmpty (NonEmpty ((:|)))
-import Data.List.NonEmpty qualified as NE
 import Data.Map qualified as M
-import Data.Maybe (catMaybes, fromMaybe, isNothing, listToMaybe)
+import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, listToMaybe, mapMaybe)
+import Data.Ord (Down (Down))
+import Data.Sequence ((><))
 import Data.Sequence qualified as Seq
 import Data.Set (Set)
 import Data.Set qualified as S
 import Data.Text (Text)
 import Data.Text qualified as T
+import Data.Time (getZonedTime)
 import Data.Tuple (swap)
-import Linear (V2 (..), zero, (^+^))
+import Linear (zero)
 import Swarm.Game.CESK
 import Swarm.Game.Display
 import Swarm.Game.Entity hiding (empty, lookup, singleton, union)
@@ -56,18 +57,23 @@
 import Swarm.Game.Exception
 import Swarm.Game.Recipe
 import Swarm.Game.Robot
-import Swarm.Game.Scenario (objectiveCondition)
+import Swarm.Game.Scenario.Objective qualified as OB
+import Swarm.Game.Scenario.Objective.WinCheck qualified as WC
 import Swarm.Game.State
-import Swarm.Game.Value
 import Swarm.Game.World qualified as W
 import Swarm.Language.Capability
 import Swarm.Language.Context hiding (delete)
 import Swarm.Language.Pipeline
 import Swarm.Language.Pipeline.QQ (tmQ)
+import Swarm.Language.Pretty (prettyText)
 import Swarm.Language.Requirement qualified as R
 import Swarm.Language.Syntax
 import Swarm.Language.Typed (Typed (..))
+import Swarm.Language.Value
+import Swarm.TUI.Model.Achievement.Attainment
+import Swarm.TUI.Model.Achievement.Definitions
 import Swarm.Util
+import Swarm.Util.Location
 import System.Clock (TimeSpec)
 import System.Clock qualified
 import System.Random (UniformRange, uniformR)
@@ -126,30 +132,134 @@
   -- Possibly see if the winning condition for the current objective is met.
   wc <- use winCondition
   case wc of
-    WinConditions (obj :| objs) -> do
+    WinConditions winState oc -> do
       g <- get @GameState
-
-      -- Execute the win condition check *hypothetically*: i.e. in a
-      -- fresh CESK machine, using a copy of the current game state.
-      v <- runThrow @Exn . evalState @GameState g $ evalPT (obj ^. objectiveCondition)
-      case v of
-        -- Log exceptions in the message queue so we can check for them in tests
-        Left exn -> do
-          em <- use entityMap
-          time <- use ticks
-          let h = hypotheticalRobot (Out VUnit emptyStore []) 0
-              hid = view robotID h
-              hn = view robotName h
-              farAway = V2 maxBound maxBound
-          let m = LogEntry time ErrorTrace hn hid farAway $ formatExn em exn
-          emitMessage m
-        Right (VBool True) -> winCondition .= maybe (Won False) WinConditions (NE.nonEmpty objs)
-        _ -> return ()
+      em <- use entityMap
+      hypotheticalWinCheck em g winState oc
     _ -> return ()
 
   -- Advance the game time by one.
   ticks += 1
 
+-- | An accumulator for folding over the incomplete
+-- objectives to evaluate for their completion
+data CompletionsWithExceptions = CompletionsWithExceptions
+  { exceptions :: [Text]
+  , completions :: ObjectiveCompletion
+  , completionAnnouncementQueue :: [OB.Objective]
+  -- ^ Upon completion, an objective is enqueued.
+  -- It is dequeued when displayed on the UI.
+  }
+
+-- | Execute the win condition check *hypothetically*: i.e. in a
+-- fresh CESK machine, using a copy of the current game state.
+--
+-- The win check is performed only on "active" goals; that is,
+-- the goals that are currently unmet and have had all of their
+-- prerequisites satisfied.
+-- Note that it may be possible, while traversing through the
+-- goal list, for one goal to be met earlier in the list that
+-- happens to be a prerequisite later in the traversal. This
+-- is why:
+-- 1) We must not pre-filter the goals to be traversed based
+--    on satisfied prerequisites (i.e. we cannot use the
+--    "getActiveObjectives" function).
+-- 2) The traversal order must be "reverse topological" order, so
+--    that prerequisites are evaluated before dependent goals.
+-- 3) The iteration needs to be a "fold", so that state is updated
+--    after each element.
+hypotheticalWinCheck ::
+  (Has (State GameState) sig m, Has (Lift IO) sig m) =>
+  EntityMap ->
+  GameState ->
+  WinStatus ->
+  ObjectiveCompletion ->
+  m ()
+hypotheticalWinCheck em g ws oc = do
+  -- We can fully and accurately evaluate the new state of the objectives DAG
+  -- in a single pass, so long as we visit it in reverse topological order.
+  --
+  -- N.B. The "reverse" is essential due to the re-population of the
+  -- "incomplete" goal list by cons-ing.
+  finalAccumulator <-
+    foldM foldFunc initialAccumulator $
+      reverse incompleteGoals
+
+  let newWinState = case ws of
+        Ongoing -> getNextWinState $ completions finalAccumulator
+        _ -> ws
+
+  winCondition .= WinConditions newWinState (completions finalAccumulator)
+
+  case newWinState of
+    Unwinnable _ -> do
+      currentTime <- sendIO getZonedTime
+      gameAchievements
+        %= M.insertWith
+          (<>)
+          LoseScenario
+          (Attainment (GameplayAchievement LoseScenario) Nothing currentTime)
+    _ -> return ()
+
+  announcementQueue %= (>< Seq.fromList (map ObjectiveCompleted $ completionAnnouncementQueue finalAccumulator))
+
+  mapM_ handleException $ exceptions finalAccumulator
+ where
+  getNextWinState completedObjs
+    | WC.didWin completedObjs = Won False
+    | WC.didLose completedObjs = Unwinnable False
+    | otherwise = Ongoing
+
+  (withoutIncomplete, incompleteGoals) = OB.extractIncomplete oc
+  initialAccumulator = CompletionsWithExceptions [] withoutIncomplete []
+
+  -- All of the "incomplete" goals have been emptied from the initial accumulator, and
+  -- these are what we iterate over with the fold.
+  -- Each iteration, we either place the goal back into the "incomplete" bucket, or
+  -- we determine that it has been met or impossible and place it into the "completed"
+  -- or "unwinnable" bucket, respectively.
+  foldFunc (CompletionsWithExceptions exnTexts currentCompletions announcements) obj = do
+    v <-
+      if WC.isPrereqsSatisfied currentCompletions obj
+        then runThrow @Exn . evalState @GameState g $ evalPT $ obj ^. OB.objectiveCondition
+        else return $ Right $ VBool False
+    let simplified = simplifyResult $ stripVResult <$> v
+    return $ case simplified of
+      Left exnText ->
+        CompletionsWithExceptions
+          (exnText : exnTexts)
+          currentCompletions
+          announcements
+      Right boolResult ->
+        CompletionsWithExceptions
+          exnTexts
+          (modifyCompletions obj currentCompletions)
+          (modifyAnnouncements announcements)
+       where
+        (modifyCompletions, modifyAnnouncements)
+          | boolResult = (OB.addCompleted, (obj :))
+          | WC.isUnwinnable currentCompletions obj = (OB.addUnwinnable, id)
+          | otherwise = (OB.addIncomplete, id)
+
+  simplifyResult = \case
+    Left exn -> Left $ formatExn em exn
+    Right (VBool x) -> Right x
+    Right val ->
+      Left $
+        T.unwords
+          [ "Non boolean value:"
+          , prettyValue val
+          , "real:"
+          , T.pack (show val)
+          ]
+
+  -- Log exceptions in the message queue so we can check for them in tests
+  handleException exnText = do
+    m <- evalState @Robot h $ createLogEntry (ErrorTrace Critical) exnText
+    emitMessage m
+   where
+    h = hypotheticalRobot (Out VUnit emptyStore []) 0
+
 evalPT ::
   (Has (Lift IO) sig m, Has (Throw Exn) sig m, Has (State GameState) sig m) =>
   ProcessedTerm ->
@@ -206,12 +316,12 @@
   return a
 
 -- | Get the entity (if any) at a given location.
-entityAt :: (Has (State GameState) sig m) => V2 Int64 -> m (Maybe Entity)
+entityAt :: (Has (State GameState) sig m) => Location -> m (Maybe Entity)
 entityAt loc = zoomWorld (W.lookupEntityM @Int (W.locToCoords loc))
 
 -- | Modify the entity (if any) at a given location.
 updateEntityAt ::
-  (Has (State GameState) sig m) => V2 Int64 -> (Maybe Entity -> Maybe Entity) -> m ()
+  (Has (State GameState) sig m) => Location -> (Maybe Entity -> Maybe Entity) -> m ()
 updateEntityAt loc upd = zoomWorld (W.updateM @Int (W.locToCoords loc) upd)
 
 -- | Get the robot with a given ID.
@@ -312,7 +422,7 @@
       robotCaps <- use robotCapabilities
       let hasCaps = cap `S.member` robotCaps
       (sys || creative || hasCaps)
-        `holdsOr` Incapable FixByInstall (R.singletonCap cap) (TConst c)
+        `holdsOr` Incapable FixByEquip (R.singletonCap cap) (TConst c)
 
 -- | Test whether the current robot has a given capability (either
 --   because it has a device which gives it that capability, or it is a
@@ -330,12 +440,16 @@
   (Has (State Robot) sig m, Has (State GameState) sig m, Has (Throw Exn) sig m) => Capability -> Term -> m ()
 hasCapabilityFor cap term = do
   h <- hasCapability cap
-  h `holdsOr` Incapable FixByInstall (R.singletonCap cap) term
+  h `holdsOr` Incapable FixByEquip (R.singletonCap cap) term
 
 -- | Create an exception about a command failing.
 cmdExn :: Const -> [Text] -> Exn
-cmdExn c parts = CmdFailed c (T.unwords parts)
+cmdExn c parts = CmdFailed c (T.unwords parts) Nothing
 
+-- | Create an exception about a command failing, with an achievement
+cmdExnWithAchievement :: Const -> [Text] -> GameplayAchievement -> Exn
+cmdExnWithAchievement c parts a = CmdFailed c (T.unwords parts) $ Just a
+
 -- | Raise an exception about a command failing with a formatted error message.
 raise :: (Has (Throw Exn) sig m) => Const -> [Text] -> m a
 raise c parts = throwError (cmdExn c parts)
@@ -369,7 +483,7 @@
 tickRobotRec :: (Has (State GameState) sig m, Has (Lift IO) sig m) => Robot -> m Robot
 tickRobotRec r
   | isActive r && (r ^. runningAtomic || r ^. tickSteps > 0) =
-    stepRobot r >>= tickRobotRec
+      stepRobot r >>= tickRobotRec
   | otherwise = return r
 
 -- | Single-step a robot by decrementing its 'tickSteps' counter and
@@ -377,14 +491,28 @@
 stepRobot :: (Has (State GameState) sig m, Has (Lift IO) sig m) => Robot -> m Robot
 stepRobot r = do
   (r', cesk') <- runState (r & tickSteps -~ 1) (stepCESK (r ^. machine))
+  -- sendIO $ appendFile "out.txt" (prettyString cesk' ++ "\n")
   return $ r' & machine .~ cesk'
 
+-- replace some entity in the world with another entity
+updateWorld ::
+  (Has (State GameState) sig m, Has (Throw Exn) sig m) =>
+  Const ->
+  WorldUpdate Entity ->
+  m ()
+updateWorld c (ReplaceEntity loc eThen down) = do
+  w <- use world
+  let eNow = W.lookupEntity (W.locToCoords loc) w
+  if Just eThen /= eNow
+    then throwError $ cmdExn c ["The", eThen ^. entityName, "is not there."]
+    else do
+      world %= W.update (W.locToCoords loc) (const down)
+      pure ()
+
 -- | The main CESK machine workhorse.  Given a robot, look at its CESK
 --   machine state and figure out a single next step.
 stepCESK :: (Has (State GameState) sig m, Has (State Robot) sig m, Has (Lift IO) sig m) => CESK -> m CESK
 stepCESK cesk = case cesk of
-  -- (sendIO $ appendFile "out.txt" (prettyCESK cesk)) >>
-
   ------------------------------------------------------------
   -- Evaluation
 
@@ -395,13 +523,14 @@
     if wakeupTime <= time
       then stepCESK cesk'
       else return cesk
-  Out v s (FImmediate wf rf : k) -> do
-    wc <- worldUpdate wf <$> use world
+  Out v s (FImmediate cmd wf rf : k) -> do
+    wc <- runError $ mapM_ (updateWorld cmd) wf
     case wc of
       Left exn -> return $ Up exn s k
-      Right wo -> do
-        robotInventory %= robotUpdateInventory rf
-        world .= wo
+      Right () -> do
+        forM_ rf $ \case
+          AddEntity c e -> robotInventory %= E.insertCount c e
+          LearnEntity e -> robotInventory %= E.insertCount 0 e
         needsRedraw .= True
         stepCESK (Out v s k)
 
@@ -458,7 +587,7 @@
   Out v2 s (FApp (VCApp c args) : k)
     | not (isCmd c)
         && arity c == length args + 1 ->
-      evalConst c (reverse (v2 : args)) s k
+        evalConst c (reverse (v2 : args)) s k
     | otherwise -> return $ Out (VCApp c (v2 : args)) s k
   Out _ s (FApp _ : _) -> badMachineState s "FApp of non-function"
   -- To evaluate non-recursive let expressions, we start by focusing on the
@@ -527,6 +656,15 @@
     runningAtomic .= False
     return $ Out v s k
 
+  -- Machinery for implementing the 'meetAll' command.
+  -- First case: done meeting everyone.
+  Out b s (FMeetAll _ [] : k) -> return $ Out b s k
+  -- More still to meet: apply the function to the current value b and
+  -- then the next robot id.  This will result in a command which we
+  -- execute, discard any generated environment, and then pass the
+  -- result to continue meeting the rest of the robots.
+  Out b s (FMeetAll f (rid : rids) : k) ->
+    return $ Out b s (FApp f : FArg (TRobot rid) empty : FExec : FDiscardEnv : FMeetAll f rids : k)
   -- To execute a bind expression, evaluate and execute the first
   -- command, and remember the second for execution later.
   Out (VBind mx c1 c2 e) s (FExec : k) -> return $ In c1 e s (FExec : FBind mx c2 e : k)
@@ -558,6 +696,9 @@
   -- Or, if a command completes with no environment, but there is a
   -- previous environment to union with, just use that environment.
   Out v s (FUnionEnv e : k) -> return $ Out (VResult v e) s k
+  -- If there's an explicit DiscardEnv frame, throw away any returned environment.
+  Out (VResult v _) s (FDiscardEnv : k) -> return $ Out v s k
+  Out v s (FDiscardEnv : k) -> return $ Out v s k
   -- If the top of the continuation stack contains a 'FLoadEnv' frame,
   -- it means we are supposed to load up the resulting definition
   -- environment, store, and type and capability contexts into the robot's
@@ -594,21 +735,34 @@
   -- First, if we were running a try block but evaluation completed normally,
   -- just ignore the try block and continue.
   Out v s (FTry {} : k) -> return $ Out v s k
-  -- If an exception rises all the way to the top level without being
-  -- handled, turn it into an error message.
-
-  -- HOWEVER, we have to make sure to check that the robot has the
-  -- 'log' capability which is required to collect and view logs.
-  --
-  -- Notice how we call resetBlackholes on the store, so that any
-  -- cells which were in the middle of being evaluated will be reset.
   Up exn s [] -> do
+    -- Here, an exception has risen all the way to the top level without being
+    -- handled.
+    case exn of
+      CmdFailed _ _ (Just a) -> do
+        currentTime <- sendIO getZonedTime
+        scenarioPath <- use currentScenarioPath
+        gameAchievements
+          %= M.insertWith
+            (<>)
+            a
+            (Attainment (GameplayAchievement a) scenarioPath currentTime)
+      _ -> return ()
+
+    -- If an exception rises all the way to the top level without being
+    -- handled, turn it into an error message.
+
+    -- HOWEVER, we have to make sure to check that the robot has the
+    -- 'log' capability which is required to collect and view logs.
+    --
+    -- Notice how we call resetBlackholes on the store, so that any
+    -- cells which were in the middle of being evaluated will be reset.
     let s' = resetBlackholes s
     h <- hasCapability CLog
     em <- use entityMap
     if h
       then do
-        void $ traceLog ErrorTrace (formatExn em exn)
+        void $ traceLog (ErrorTrace Error) (formatExn em exn)
         return $ Out VUnit s []
       else return $ Out VUnit s' []
   -- Fatal errors, capability errors, and infinite loop errors can't
@@ -630,7 +784,7 @@
     let msg' =
           T.unlines
             [ T.append "Bad machine state in stepRobot: " msg
-            , from (prettyCESK cesk)
+            , prettyText cesk
             ]
      in return $ Up (Fatal msg') s []
 
@@ -681,7 +835,7 @@
 -- | Construct a "seed robot" from entity, time range and position,
 --   and add it to the world.  It has low priority and will be covered
 --   by placed entities.
-addSeedBot :: Has (State GameState) sig m => Entity -> (Integer, Integer) -> V2 Int64 -> TimeSpec -> m ()
+addSeedBot :: Has (State GameState) sig m => Entity -> (Integer, Integer) -> Location -> TimeSpec -> m ()
 addSeedBot e (minT, maxT) loc ts =
   void $
     addTRobot $
@@ -691,7 +845,7 @@
         "seed"
         ["A growing seed."]
         (Just loc)
-        (V2 0 0)
+        zero
         ( defaultEntityDisplay '.'
             & displayAttr .~ (e ^. entityDisplay . displayAttr)
             & displayPriority .~ 0
@@ -734,14 +888,14 @@
         return $ Waiting (time + d) (Out VUnit s k)
       _ -> badConst
     Selfdestruct -> do
-      destroyIfNotBase
+      destroyIfNotBase $ Just AttemptSelfDestructBase
       flagRedraw
       return $ Out VUnit s k
     Move -> do
       -- Figure out where we're going
       loc <- use robotLocation
       orient <- use robotOrientation
-      let nextLoc = loc ^+^ (orient ? zero)
+      let nextLoc = loc .+^ (orient ? zero)
       checkMoveAhead nextLoc $
         MoveFailure
           { failIfBlocked = ThrowExn
@@ -755,7 +909,7 @@
         target <- getRobotWithinTouch rid
         -- either change current robot or one in robot map
         let oldLoc = target ^. robotLocation
-            nextLoc = V2 (fromIntegral x) (fromIntegral y)
+            nextLoc = Location (fromIntegral x) (fromIntegral y)
 
         onTarget rid $ do
           checkMoveAhead nextLoc $
@@ -834,44 +988,37 @@
 
         return $ Out VUnit s k
       _ -> badConst
-    Install -> case vs of
-      [VRobot otherID, VText itemName] -> do
-        -- Make sure the other robot exists and is close
-        _other <- getRobotWithinTouch otherID
-
-        item <- ensureItem itemName "install"
-
+    Equip -> case vs of
+      [VText itemName] -> do
+        item <- ensureItem itemName "equip"
         myID <- use robotID
         focusedID <- use focusedRobotID
-        case otherID == myID of
-          -- We have to special case installing something on ourselves
-          -- for the same reason as Give.
-          True -> do
-            -- Don't do anything if the robot already has the device.
-            already <- use (installedDevices . to (`E.contains` item))
-            unless already $ do
-              installedDevices %= insert item
-              robotInventory %= delete item
-
-              -- Flag the UI for a redraw if we are currently showing our inventory
-              when (focusedID == myID) flagRedraw
-          False -> do
-            let otherDevices = robotMap . at otherID . _Just . installedDevices
-            already <- use $ pre (otherDevices . to (`E.contains` item))
-            unless (already == Just True) $ do
-              robotMap . at otherID . _Just . installedDevices %= insert item
-              robotInventory %= delete item
+        -- Don't do anything if the robot already has the device.
+        already <- use (equippedDevices . to (`E.contains` item))
+        unless already $ do
+          equippedDevices %= insert item
+          robotInventory %= delete item
 
-              -- Flag the UI for a redraw if we are currently showing
-              -- either robot's inventory
-              when (focusedID == myID || focusedID == otherID) flagRedraw
+          -- Flag the UI for a redraw if we are currently showing our inventory
+          when (focusedID == myID) flagRedraw
 
         return $ Out VUnit s k
       _ -> badConst
+    Unequip -> case vs of
+      [VText itemName] -> do
+        item <- ensureEquipped itemName
+        myID <- use robotID
+        focusedID <- use focusedRobotID
+        equippedDevices %= delete item
+        robotInventory %= insert item
+        -- Flag the UI for a redraw if we are currently showing our inventory
+        when (focusedID == myID) flagRedraw
+        return $ Out VUnit s k
+      _ -> badConst
     Make -> case vs of
       [VText name] -> do
         inv <- use robotInventory
-        ins <- use installedDevices
+        ins <- use equippedDevices
         em <- use entityMap
         e <-
           lookupEntityName name em
@@ -893,7 +1040,7 @@
 
         let displayMissingCount mc = \case
               MissingInput -> from (show mc)
-              MissingCatalyst -> "not installed"
+              MissingCatalyst -> "not equipped"
             displayMissingIngredient (MissingIngredient mk mc me) =
               "  - " <> me ^. entityName <> " (" <> displayMissingCount mc mk <> ")"
             displayMissingIngredients xs = L.intercalate ["OR"] (map displayMissingIngredient <$> xs)
@@ -913,16 +1060,16 @@
         -- take recipe inputs from inventory and add outputs after recipeTime
         robotInventory .= invTaken
         traverse_ (updateDiscoveredEntities . snd) (recipe ^. recipeOutputs)
-        finishCookingRecipe recipe (WorldUpdate Right) (RobotUpdate changeInv)
+        finishCookingRecipe recipe [] (map (uncurry AddEntity) changeInv)
       _ -> badConst
     Has -> case vs of
       [VText name] -> do
         inv <- use robotInventory
         return $ Out (VBool ((> 0) $ countByName name inv)) s k
       _ -> badConst
-    Installed -> case vs of
+    Equipped -> case vs of
       [VText name] -> do
-        inv <- use installedDevices
+        inv <- use equippedDevices
         return $ Out (VBool ((> 0) $ countByName name inv)) s k
       _ -> badConst
     Count -> case vs of
@@ -931,8 +1078,20 @@
         return $ Out (VInt (fromIntegral $ countByName name inv)) s k
       _ -> badConst
     Whereami -> do
-      V2 x y <- use robotLocation
+      loc <- use robotLocation
+      let Location x y = loc
       return $ Out (VPair (VInt (fromIntegral x)) (VInt (fromIntegral y))) s k
+    Heading -> do
+      mh <- use robotOrientation
+      -- In general, (1) entities might not have an orientation, and
+      -- (2) even if they do, orientation is a general vector, which
+      -- might not correspond to a cardinal direction.  We could make
+      -- 'heading' return a 'maybe dir' i.e. 'unit + dir', or return a
+      -- vector of type 'int * int', but those would both be annoying
+      -- for players in the vast majority of cases.  We rather choose
+      -- to just return the direction 'down' in any case where we don't
+      -- otherwise have anything reasonable to return.
+      return $ Out (VDir (fromMaybe (DRelative DDown) $ mh >>= toDirection)) s k
     Time -> do
       t <- use ticks
       return $ Out (VInt t) s k
@@ -940,18 +1099,19 @@
       [VDir d] -> do
         rname <- use robotName
         inv <- use robotInventory
-        ins <- use installedDevices
+        ins <- use equippedDevices
 
-        let toyDrill = lookupByName "drill" ins
-            metalDrill = lookupByName "metal drill" ins
-            insDrill = listToMaybe $ metalDrill <> toyDrill
+        let equippedDrills = extantElemsWithCapability CDrill ins
+            -- Heuristic: choose the drill with the more elaborate name.
+            -- E.g. "metal drill" vs. "drill"
+            preferredDrill = listToMaybe $ sortOn (Down . T.length . (^. entityName)) equippedDrills
 
-        drill <- insDrill `isJustOr` Fatal "Drill is required but not installed?!"
+        drill <- preferredDrill `isJustOr` Fatal "Drill is required but not equipped?!"
 
         let directionText = case d of
-              DDown -> "under"
-              DForward -> "ahead of"
-              DBack -> "behind"
+              DRelative DDown -> "under"
+              DRelative DForward -> "ahead of"
+              DRelative DBack -> "behind"
               _ -> dirSyntax (dirInfo d) <> " of"
 
         (nextLoc, nextME) <- lookInDirection d
@@ -974,19 +1134,28 @@
             `isJustOrFail` ["You don't have the ingredients to drill", indefinite (nextE ^. entityName) <> "."]
 
         let (out, down) = L.partition ((`hasProperty` Portable) . snd) outs
-            changeInv =
-              flip (L.foldl' (flip $ uncurry insertCount)) out
-                . flip (L.foldl' (flip $ insertCount 0)) (map snd down)
-            changeWorld = changeWorld' nextE nextLoc down
+        let learn = map (LearnEntity . snd) down
+        let gain = map (uncurry AddEntity) out
 
+        newEntity <- case down of
+          [] -> pure Nothing
+          [(1, de)] -> pure $ Just de
+          _ -> throwError $ Fatal "Bad recipe:\n more than one unmovable entity produced."
+        let changeWorld =
+              ReplaceEntity
+                { updatedLoc = nextLoc
+                , originalEntity = nextE
+                , newEntity = newEntity
+                }
+
         -- take recipe inputs from inventory and add outputs after recipeTime
         robotInventory .= invTaken
-        finishCookingRecipe recipe (WorldUpdate changeWorld) (RobotUpdate changeInv)
+        finishCookingRecipe recipe [changeWorld] (learn <> gain)
       _ -> badConst
     Blocked -> do
       loc <- use robotLocation
       orient <- use robotOrientation
-      let nextLoc = loc ^+^ (orient ? zero)
+      let nextLoc = loc .+^ (orient ? zero)
       me <- entityAt nextLoc
       return $ Out (VBool (maybe False (`hasProperty` Unwalkable) me)) s k
     Scan -> case vs of
@@ -1004,7 +1173,7 @@
     Knows -> case vs of
       [VText name] -> do
         inv <- use robotInventory
-        ins <- use installedDevices
+        ins <- use equippedDevices
         let allKnown = inv `E.union` ins
         let knows = case E.lookupByName name allKnown of
               [] -> False
@@ -1043,7 +1212,7 @@
     As -> case vs of
       [VRobot rid, prog] -> do
         -- Get the named robot and current game state
-        r <- robotWithID rid >>= (`isJustOrFail` ["There is no robot with ID", from (show rid)])
+        r <- robotWithID rid >>= (`isJustOrFail` ["There is no actor with ID", from (show rid)])
         g <- get @GameState
 
         -- Execute the given program *hypothetically*: i.e. in a fresh
@@ -1082,7 +1251,7 @@
         m <- traceLog Said msg -- current robot will inserted to robot set, so it needs the log
         emitMessage m
         let addLatestClosest rl = \case
-              Seq.Empty -> Seq.Empty
+              Seq.Empty -> Seq.singleton m
               es Seq.:|> e
                 | e ^. leTime < m ^. leTime -> es |> e |> m
                 | manhattan rl (e ^. leLocation) > manhattan rl (m ^. leLocation) -> es |> m
@@ -1129,7 +1298,7 @@
       [VRobot rid] -> do
         _ <-
           robotWithID rid
-            >>= (`isJustOrFail` ["There is no robot with ID", from (show rid), "to view."])
+            >>= (`isJustOrFail` ["There is no actor with ID", from (show rid), "to view."])
 
         -- Only the base can actually change the view in the UI.  Other robots can
         -- execute this command but it does nothing (at least for now).
@@ -1176,6 +1345,10 @@
           Nothing -> return $ Out (VBool False) s k
           Just e -> return $ Out (VBool (T.toLower (e ^. entityName) == T.toLower name)) s k
       _ -> badConst
+    Isempty -> do
+      loc <- use robotLocation
+      me <- entityAt loc
+      return $ Out (VBool (isNothing me)) s k
     Self -> do
       rid <- use robotID
       return $ Out (VRobot rid) s k
@@ -1184,6 +1357,23 @@
       rid <- use robotID
       return $ Out (VRobot (fromMaybe rid mp)) s k
     Base -> return $ Out (VRobot 0) s k
+    Meet -> do
+      loc <- use robotLocation
+      rid <- use robotID
+      g <- get @GameState
+      let neighbor =
+            find ((/= rid) . (^. robotID)) -- pick one other than ourself
+              . sortOn (manhattan loc . (^. robotLocation)) -- prefer closer
+              $ robotsInArea loc 1 g -- all robots within Manhattan distance 1
+      return $ Out (VInj (isJust neighbor) (maybe VUnit (VRobot . (^. robotID)) neighbor)) s k
+    MeetAll -> case vs of
+      [f, b] -> do
+        loc <- use robotLocation
+        rid <- use robotID
+        g <- get @GameState
+        let neighborIDs = filter (/= rid) . map (^. robotID) $ robotsInArea loc 1 g
+        return $ Out b s (FMeetAll f neighborIDs : k)
+      _ -> badConst
     Whoami -> case vs of
       [] -> do
         name <- use robotName
@@ -1258,7 +1448,7 @@
         -- check if robot exists
         childRobot <-
           robotWithID childRobotID
-            >>= (`isJustOrFail` ["There is no robot with ID", from (show childRobotID) <> "."])
+            >>= (`isJustOrFail` ["There is no actor with ID", from (show childRobotID) <> "."])
 
         -- check that current robot is not trying to reprogram self
         myID <- use robotID
@@ -1279,11 +1469,11 @@
 
         -- Figure out if we can supply what the target robot requires,
         -- and if so, what is needed.
-        (toInstall, toGive) <-
+        (toEquip, toGive) <-
           checkRequirements
             (r ^. robotInventory)
             (childRobot ^. robotInventory)
-            (childRobot ^. installedDevices)
+            (childRobot ^. equippedDevices)
             cmd
             "The target robot"
             FixByObtain
@@ -1297,7 +1487,7 @@
 
         -- Provision the target robot with any required devices and
         -- inventory that are lacking.
-        provisionChild childRobotID (fromList . S.toList $ toInstall) toGive
+        provisionChild childRobotID (fromList . S.toList $ toEquip) toGive
 
         -- Finally, re-activate the reprogrammed target robot.
         activateRobot childRobotID
@@ -1331,7 +1521,7 @@
         r <- get @Robot
         pid <- use robotID
 
-        (toInstall, toGive) <-
+        (toEquip, toGive) <-
           checkRequirements (r ^. robotInventory) E.empty E.empty cmd "You" FixByObtain
 
         -- Pick a random display name.
@@ -1339,8 +1529,9 @@
         createdAt <- getNow
 
         -- Construct the new robot and add it to the world.
+        parentCtx <- use robotContext
         newRobot <-
-          addTRobot $
+          addTRobot . (trobotContext .~ parentCtx) $
             mkRobot
               ()
               (Just pid)
@@ -1359,7 +1550,7 @@
               createdAt
 
         -- Provision the new robot with the necessary devices and inventory.
-        provisionChild (newRobot ^. robotID) (fromList . S.toList $ toInstall) toGive
+        provisionChild (newRobot ^. robotID) (fromList . S.toList $ toEquip) toGive
 
         -- Flag the world for a redraw and return the name of the newly constructed robot.
         flagRedraw
@@ -1373,16 +1564,16 @@
         case mtarget of
           Nothing -> return $ Out VUnit s k -- Nothing to salvage
           Just target -> do
-            -- Copy the salvaged robot's installed devices into its inventory, in preparation
+            -- Copy the salvaged robot's equipped devices into its inventory, in preparation
             -- for transferring it.
-            let salvageInventory = E.union (target ^. robotInventory) (target ^. installedDevices)
+            let salvageInventory = E.union (target ^. robotInventory) (target ^. equippedDevices)
             robotMap . at (target ^. robotID) . traverse . robotInventory .= salvageInventory
 
             let salvageItems = concatMap (\(n, e) -> replicate n (e ^. entityName)) (E.elems salvageInventory)
                 numItems = length salvageItems
 
             -- Copy over the salvaged robot's log, if we have one
-            inst <- use installedDevices
+            inst <- use equippedDevices
             em <- use entityMap
             creative <- use creativeMode
             logger <-
@@ -1409,7 +1600,10 @@
                 giveItem item = TApp (TApp (TConst Give) (TRobot ourID)) (TText item)
 
             -- Reprogram and activate the salvaged robot
-            robotMap . at (target ^. robotID) . traverse . machine
+            robotMap
+              . at (target ^. robotID)
+              . traverse
+              . machine
               .= In giveInventory empty emptyStore [FExec]
             activateRobot (target ^. robotID)
 
@@ -1433,9 +1627,15 @@
           processTerm (into @Text f) `isRightOr` \err ->
             cmdExn Run ["Error in", fileName, "\n", err]
 
-        return $ case mt of
-          Nothing -> Out VUnit s k
-          Just t -> initMachine' t empty s k
+        case mt of
+          Nothing -> return $ Out VUnit s k
+          Just t@(ProcessedTerm _ _ reqCtx) -> do
+            -- Add the reqCtx from the ProcessedTerm to the current robot's defReqs.
+            -- See #827 for an explanation of (1) why this is needed, (2) why
+            -- it's slightly technically incorrect, and (3) why it is still way
+            -- better than what we had before.
+            robotContext . defReqs <>= reqCtx
+            return $ initMachine' t empty s k
       _ -> badConst
     Not -> case vs of
       [VBool b] -> return $ Out (VBool (not b)) s k
@@ -1475,6 +1675,19 @@
     Concat -> case vs of
       [VText v1, VText v2] -> return $ Out (VText (v1 <> v2)) s k
       _ -> badConst
+    CharAt -> case vs of
+      [VInt i, VText t]
+        | i < 0 || i >= fromIntegral (T.length t) ->
+            raise CharAt ["Index", prettyValue (VInt i), "out of bounds for length", from @String $ show (T.length t)]
+        | otherwise -> return $ Out (VInt . fromIntegral . ord . T.index t . fromIntegral $ i) s k
+      _ -> badConst
+    ToChar -> case vs of
+      [VInt i]
+        | i < 0 || i > fromIntegral (ord (maxBound :: Char)) ->
+            raise ToChar ["Value", prettyValue (VInt i), "is an invalid character code"]
+        | otherwise ->
+            return $ Out (VText . T.singleton . chr . fromIntegral $ i) s k
+      _ -> badConst
     AppF ->
       let msg = "The operator '$' should only be a syntactic sugar and removed in elaboration:\n"
        in throwError . Fatal $ msg <> badConstMsg
@@ -1486,28 +1699,37 @@
   badConstMsg =
     T.unlines
       [ "Bad application of execConst:"
-      , from (prettyCESK (Out (VCApp c (reverse vs)) s k))
+      , T.pack (show c)
+      , T.pack (show (reverse vs))
+      , prettyText (Out (VCApp c (reverse vs)) s k)
       ]
 
-  finishCookingRecipe :: HasRobotStepState sig m => Recipe e -> WorldUpdate -> RobotUpdate -> m CESK
+  finishCookingRecipe :: HasRobotStepState sig m => Recipe e -> [WorldUpdate Entity] -> [RobotUpdate] -> m CESK
   finishCookingRecipe r wf rf = do
     time <- use ticks
     let remTime = r ^. recipeTime
     return . (if remTime <= 1 then id else Waiting (remTime + time)) $
-      Out VUnit s (FImmediate wf rf : k)
+      Out VUnit s (FImmediate c wf rf : k)
 
-  lookInDirection :: HasRobotStepState sig m => Direction -> m (V2 Int64, Maybe Entity)
+  lookInDirection :: HasRobotStepState sig m => Direction -> m (Location, Maybe Entity)
   lookInDirection d = do
     loc <- use robotLocation
     orient <- use robotOrientation
     when (isCardinal d) $ hasCapabilityFor COrient (TDir d)
-    let nextLoc = loc ^+^ applyTurn d (orient ? zero)
+    let nextLoc = loc .+^ applyTurn d (orient ? zero)
     (nextLoc,) <$> entityAt nextLoc
+
+  ensureEquipped :: HasRobotStepState sig m => Text -> m Entity
+  ensureEquipped itemName = do
+    inst <- use equippedDevices
+    listToMaybe (lookupByName itemName inst)
+      `isJustOrFail` ["You don't have a", indefinite itemName, "equipped."]
+
   ensureItem :: HasRobotStepState sig m => Text -> Text -> m Entity
   ensureItem itemName action = do
     -- First, make sure we know about the entity.
     inv <- use robotInventory
-    inst <- use installedDevices
+    inst <- use equippedDevices
     item <-
       asum (map (listToMaybe . lookupByName itemName) [inv, inst])
         `isJustOrFail` ["What is", indefinite itemName <> "?"]
@@ -1527,14 +1749,14 @@
   -- both 'Build' and 'Reprogram'.
   --
   -- It is given as inputs the parent robot inventory, the inventory
-  -- and installed devices of the child (these will be empty in the
+  -- and equipped devices of the child (these will be empty in the
   -- case of 'Build'), and the command to be run (along with a few
   -- inputs to configure any error messages to be generated).
   --
   -- Throws an exception if it's not possible to set up the child
   -- robot with the things it needs to execute the given program.
   -- Otherwise, returns a pair consisting of the set of devices to be
-  -- installed, and the inventory that should be transferred from
+  -- equipped, and the inventory that should be transferred from
   -- parent to child.
   checkRequirements ::
     HasRobotStepState sig m =>
@@ -1557,74 +1779,94 @@
         -- See #349
         (R.Requirements (S.toList -> caps) (S.toList -> devNames) reqInvNames, _capCtx) = R.requirements currentContext cmd
 
-    -- Check that all required device names exist, and fail with
-    -- an exception if not
-    devs <- forM devNames $ \devName ->
+    -- Check that all required device names exist (fail with
+    -- an exception if not) and convert them to 'Entity' values.
+    (devs :: [Entity]) <- forM devNames $ \devName ->
       E.lookupEntityName devName em `isJustOrFail` ["Unknown device required: " <> devName]
 
-    -- Check that all required inventory entity names exist, and fail
-    -- with an exception if not
-    reqElems <- forM (M.assocs reqInvNames) $ \(eName, n) ->
+    -- Check that all required inventory entity names exist (fail with
+    -- an exception if not) and convert them to 'Entity' values, with
+    -- an associated count for each.
+    (reqInv :: Inventory) <- fmap E.fromElems . forM (M.assocs reqInvNames) $ \(eName, n) ->
       (n,)
         <$> ( E.lookupEntityName eName em
                 `isJustOrFail` ["Unknown entity required: " <> eName]
             )
-    let reqInv = E.fromElems reqElems
 
-    let -- List of possible devices per requirement.  Devices for
-        -- required capabilities come first, then singleton devices
-        -- that are required directly.  This order is important since
-        -- later we zip required capabilities with this list to figure
-        -- out which capabilities are missing.
-        capDevices = map (`deviceForCap` em) caps ++ map (: []) devs
+    let -- List of possible devices per requirement.  For the
+        -- requirements that stem from a required capability, we
+        -- remember the capability alongside the possible devices, to
+        -- help with later error message generation.
+        possibleDevices :: [(Maybe Capability, [Entity])]
+        possibleDevices =
+          map (Just &&& (`deviceForCap` em)) caps -- Possible devices for capabilities
+            ++ map ((Nothing,) . (: [])) devs -- Outright required devices
 
         -- A device is OK if it is available in the inventory of the
-        -- parent robot, or already installed in the child robot.
+        -- parent robot, or already equipped in the child robot.
+        deviceOK :: Entity -> Bool
         deviceOK d = parentInventory `E.contains` d || childDevices `E.contains` d
 
-        -- take a pair of device sets providing capabilities that is
-        -- split into (AVAIL,MISSING) and if there are some available
-        -- ignore missing because we only need them for error message
-        ignoreOK ([], miss) = ([], miss)
-        ignoreOK (ds, _miss) = (ds, [])
-
-        (deviceSets, missingDeviceSets) =
-          Lens.over both (nubOrd . map S.fromList) . unzip $
-            map (ignoreOK . L.partition deviceOK) capDevices
-
-        formatDevices = T.intercalate " or " . map (^. entityName) . S.toList
-        -- capabilities not provided by any device in inventory
-        missingCaps = S.fromList . map fst . filter (null . snd) $ zip caps deviceSets
+        -- Partition each list of possible devices into a set of
+        -- available devices and a set of unavailable devices.
+        -- There's a problem if some capability is required but no
+        -- devices that provide it are available.  In that case we can
+        -- print an error message, using the second set as a list of
+        -- suggestions.
+        partitionedDevices :: [(Set Entity, Set Entity)]
+        partitionedDevices =
+          map (Lens.over both S.fromList . L.partition deviceOK . snd) possibleDevices
 
-        alreadyInstalled = S.fromList . map snd . E.elems $ childDevices
+        -- Devices equipped on the child, as a Set instead of an
+        -- Inventory for convenience.
+        alreadyEquipped :: Set Entity
+        alreadyEquipped = S.fromList . map snd . E.elems $ childDevices
 
-        -- Figure out what is missing from the required inventory
+        -- Figure out what is still missing of the required inventory:
+        -- the required inventory, less any inventory the child robot
+        -- already has.
         missingChildInv = reqInv `E.difference` childInventory
 
     if creative
-      then -- In creative mode, just return ALL the devices
-        return (S.unions (map S.fromList capDevices) `S.difference` alreadyInstalled, missingChildInv)
+      then
+        return
+          ( -- In creative mode, just equip ALL the devices
+            -- providing each required capability (because, why
+            -- not?). But don't re-equip any that are already
+            -- equipped.
+            S.unions (map (S.fromList . snd) possibleDevices) `S.difference` alreadyEquipped
+          , -- Conjure the necessary missing inventory out of thin
+            -- air.
+            missingChildInv
+          )
       else do
-        -- check if robot has all devices to execute new command
-        all null missingDeviceSets
-          `holdsOrFail` ( singularSubjectVerb subject "do" :
-                          "not have required devices, please" :
-                          formatIncapableFix fixI <> ":" :
-                          (("\n  - " <>) . formatDevices <$> filter (not . null) missingDeviceSets)
+        -- First, check that devices actually exist AT ALL to provide every
+        -- required capability.  If not, we will generate an error message saying
+        -- something like "missing capability X but no device yet provides it".
+        let capsWithNoDevice = mapMaybe fst . filter (null . snd) $ possibleDevices
+        null capsWithNoDevice
+          `holdsOr` Incapable fixI (R.Requirements (S.fromList capsWithNoDevice) S.empty M.empty) cmd
+
+        -- Now, ensure there is at least one device available to be
+        -- equipped for each requirement.
+        let missingDevices = map snd . filter (null . fst) $ partitionedDevices
+        null missingDevices
+          `holdsOrFail` ( singularSubjectVerb subject "do"
+                            : "not have required devices, please"
+                            : formatIncapableFix fixI <> ":"
+                            : (("\n  - " <>) . formatDevices <$> missingDevices)
                         )
-        -- check that there are in fact devices to provide every required capability
-        not (any null deviceSets) `holdsOr` Incapable fixI (R.Requirements missingCaps S.empty M.empty) cmd
 
-        let minimalInstallSet = smallHittingSet (filter (S.null . S.intersection alreadyInstalled) deviceSets)
+        let minimalEquipSet = smallHittingSet (filter (S.null . S.intersection alreadyEquipped) (map fst partitionedDevices))
 
             -- Check that we have enough in our inventory to cover the
-            -- required installs PLUS what's missing from the child
+            -- required devices PLUS what's missing from the child
             -- inventory.
 
             -- What do we need?
             neededParentInv =
               missingChildInv
-                `E.union` (fromList . S.toList $ minimalInstallSet)
+                `E.union` (fromList . S.toList $ minimalEquipSet)
 
             -- What are we missing?
             missingParentInv = neededParentInv `E.difference` parentInventory
@@ -1639,33 +1881,19 @@
         E.isEmpty missingParentInv
           `holdsOr` Incapable fixI (R.Requirements S.empty S.empty missingMap) cmd
 
-        return (minimalInstallSet, missingChildInv)
-
-  -- replace some entity in the world with another entity
-  changeWorld' ::
-    Entity ->
-    V2 Int64 ->
-    IngredientList Entity ->
-    W.World Int Entity ->
-    Either Exn (W.World Int Entity)
-  changeWorld' eThen loc down w =
-    let eNow = W.lookupEntity (W.locToCoords loc) w
-     in if Just eThen /= eNow
-          then Left $ cmdExn c ["The", eThen ^. entityName, "is not there."]
-          else
-            w `updateLoc` loc <$> case down of
-              [] -> Right Nothing
-              [de] -> Right $ Just $ snd de
-              _ -> Left $ Fatal "Bad recipe:\n more than one unmovable entity produced."
+        return (minimalEquipSet, missingChildInv)
 
-  destroyIfNotBase :: HasRobotStepState sig m => m ()
-  destroyIfNotBase = do
+  destroyIfNotBase :: HasRobotStepState sig m => Maybe GameplayAchievement -> m ()
+  destroyIfNotBase mAch = do
     rid <- use robotID
-    (rid /= 0) `holdsOrFail` ["You consider destroying your base, but decide not to do it after all."]
+    holdsOrFailWithAchievement
+      (rid /= 0)
+      ["You consider destroying your base, but decide not to do it after all."]
+      mAch
     selfDestruct .= True
 
   -- Make sure nothing is in the way. Note that system robots implicitly ignore and base throws on failure.
-  checkMoveAhead :: HasRobotStepState sig m => V2 Int64 -> MoveFailure -> m ()
+  checkMoveAhead :: HasRobotStepState sig m => Location -> MoveFailure -> m ()
   checkMoveAhead nextLoc MoveFailure {..} = do
     me <- entityAt nextLoc
     systemRob <- use systemRobot
@@ -1674,20 +1902,20 @@
       Just e
         | systemRob -> return ()
         | otherwise -> do
-          -- robots can not walk through walls
-          when (e `hasProperty` Unwalkable) $
-            case failIfBlocked of
-              Destroy -> destroyIfNotBase
-              ThrowExn -> throwError $ cmdExn c ["There is a", e ^. entityName, "in the way!"]
-              IgnoreFail -> return ()
+            -- robots can not walk through walls
+            when (e `hasProperty` Unwalkable) $
+              case failIfBlocked of
+                Destroy -> destroyIfNotBase Nothing
+                ThrowExn -> throwError $ cmdExn c ["There is a", e ^. entityName, "in the way!"]
+                IgnoreFail -> return ()
 
-          -- robots drown if they walk over liquid without boat
-          caps <- use robotCapabilities
-          when (e `hasProperty` Liquid && CFloat `S.notMember` caps) $
-            case failIfDrown of
-              Destroy -> destroyIfNotBase
-              ThrowExn -> throwError $ cmdExn c ["There is a dangerous liquid", e ^. entityName, "in the way!"]
-              IgnoreFail -> return ()
+            -- robots drown if they walk over liquid without boat
+            caps <- use robotCapabilities
+            when (e `hasProperty` Liquid && CFloat `S.notMember` caps) $
+              case failIfDrown of
+                Destroy -> destroyIfNotBase Nothing
+                ThrowExn -> throwError $ cmdExn c ["There is a dangerous liquid", e ^. entityName, "in the way!"]
+                IgnoreFail -> return ()
 
   getRobotWithinTouch :: HasRobotStepState sig m => RID -> m Robot
   getRobotWithinTouch rid = do
@@ -1704,12 +1932,14 @@
           `holdsOrFail` ["The robot with ID", from (show rid), "is not close enough."]
         return other
 
-  -- update some tile in the world setting it to entity or making it empty
-  updateLoc w loc res = W.update (W.locToCoords loc) (const res) w
-
   holdsOrFail :: (Has (Throw Exn) sig m) => Bool -> [Text] -> m ()
   holdsOrFail a ts = a `holdsOr` cmdExn c ts
 
+  holdsOrFailWithAchievement :: (Has (Throw Exn) sig m) => Bool -> [Text] -> Maybe GameplayAchievement -> m ()
+  holdsOrFailWithAchievement a ts mAch = case mAch of
+    Nothing -> holdsOrFail a ts
+    Just ach -> a `holdsOr` cmdExnWithAchievement c ts ach
+
   isJustOrFail :: (Has (Throw Exn) sig m) => Maybe a -> [Text] -> m a
   isJustOrFail a ts = a `isJustOr` cmdExn c ts
 
@@ -1808,10 +2038,15 @@
   Grab' -> "grabbed"
   Swap' -> "swapped"
 
+-- | Format a set of suggested devices for use in an error message,
+--   in the format @device1 or device2 or ... or deviceN@.
+formatDevices :: Set Entity -> Text
+formatDevices = T.intercalate " or " . map (^. entityName) . S.toList
+
 -- | Give some entities from a parent robot (the robot represented by
 --   the ambient @State Robot@ effect) to a child robot (represented
 --   by the given 'RID') as part of a 'Build' or 'Reprogram' command.
---   The first 'Inventory' is devices to be installed, and the second
+--   The first 'Inventory' is devices to be equipped, and the second
 --   is entities to be transferred.
 --
 --   In classic mode, the entities will be /transferred/ (that is,
@@ -1824,15 +2059,15 @@
   Inventory ->
   Inventory ->
   m ()
-provisionChild childID toInstall toGive = do
-  -- Install and give devices to child
-  robotMap . ix childID . installedDevices %= E.union toInstall
+provisionChild childID toEquip toGive = do
+  -- Equip and give devices to child
+  robotMap . ix childID . equippedDevices %= E.union toEquip
   robotMap . ix childID . robotInventory %= E.union toGive
 
   -- Delete all items from parent in classic mode
   creative <- use creativeMode
   unless creative $
-    robotInventory %= (`E.difference` (toInstall `E.union` toGive))
+    robotInventory %= (`E.difference` (toEquip `E.union` toGive))
 
 -- | Update the location of a robot, and simultaneously update the
 --   'robotsByLocation' map, so we can always look up robots by
@@ -1840,17 +2075,17 @@
 --   of a robot.
 updateRobotLocation ::
   (HasRobotStepState sig m) =>
-  V2 Int64 ->
-  V2 Int64 ->
+  Location ->
+  Location ->
   m ()
 updateRobotLocation oldLoc newLoc
   | oldLoc == newLoc = return ()
   | otherwise = do
-    rid <- use robotID
-    robotsByLocation . at oldLoc %= deleteOne rid
-    robotsByLocation . at newLoc . non Empty %= IS.insert rid
-    modify (unsafeSetRobotLocation newLoc)
-    flagRedraw
+      rid <- use robotID
+      robotsByLocation . at oldLoc %= deleteOne rid
+      robotsByLocation . at newLoc . non Empty %= IS.insert rid
+      modify (unsafeSetRobotLocation newLoc)
+      flagRedraw
  where
   -- Make sure empty sets don't hang around in the
   -- robotsByLocation map.  We don't want a key with an
@@ -1943,8 +2178,9 @@
 incomparable :: Has (Throw Exn) sig m => Value -> Value -> m a
 incomparable v1 v2 =
   throwError $
-    CmdFailed Lt $
-      T.unwords ["Comparison is undefined for ", prettyValue v1, "and", prettyValue v2]
+    cmdExn
+      Lt
+      ["Comparison is undefined for ", prettyValue v1, "and", prettyValue v2]
 
 ------------------------------------------------------------
 -- Arithmetic
@@ -1967,13 +2203,13 @@
 -- | Perform an integer division, but return @Nothing@ for division by
 --   zero.
 safeDiv :: Has (Throw Exn) sig m => Integer -> Integer -> m Integer
-safeDiv _ 0 = throwError $ CmdFailed Div "Division by zero"
+safeDiv _ 0 = throwError $ cmdExn Div $ pure "Division by zero"
 safeDiv a b = return $ a `div` b
 
 -- | Perform exponentiation, but return @Nothing@ if the power is negative.
 safeExp :: Has (Throw Exn) sig m => Integer -> Integer -> m Integer
 safeExp a b
-  | b < 0 = throwError $ CmdFailed Exp "Negative exponent"
+  | b < 0 = throwError $ cmdExn Exp $ pure "Negative exponent"
   | otherwise = return $ a ^ b
 
 ------------------------------------------------------------
@@ -2013,7 +2249,7 @@
 
 updateAvailableCommands :: Has (State GameState) sig m => Entity -> m ()
 updateAvailableCommands e = do
-  let newCaps = S.fromList (e ^. entityCapabilities)
+  let newCaps = e ^. entityCapabilities
       keepConsts = \case
         Just cap -> cap `S.member` newCaps
         Nothing -> False
diff --git a/src/Swarm/Game/Value.hs b/src/Swarm/Game/Value.hs
deleted file mode 100644
--- a/src/Swarm/Game/Value.hs
+++ /dev/null
@@ -1,117 +0,0 @@
-{-# LANGUAGE DeriveAnyClass #-}
-{-# LANGUAGE DeriveGeneric #-}
-{-# LANGUAGE GADTs #-}
-
--- |
--- Module      :  Swarm.Game.Value
--- Copyright   :  Brent Yorgey
--- Maintainer  :  byorgey@gmail.com
---
--- SPDX-License-Identifier: BSD-3-Clause
---
--- Values and environments used for interpreting the Swarm language.
-module Swarm.Game.Value (
-  -- * Values
-  Value (..),
-  prettyValue,
-  valueToTerm,
-
-  -- * Environments
-  Env,
-) where
-
-import Data.Aeson (FromJSON, ToJSON)
-import Data.Bool (bool)
-import Data.List (foldl')
-import Data.Map qualified as M
-import Data.Set qualified as S
-import Data.Set.Lens (setOf)
-import Data.Text (Text)
-import GHC.Generics (Generic)
-import Swarm.Language.Context
-import Swarm.Language.Pretty (prettyText)
-import Swarm.Language.Syntax
-import Prelude
-
--- | A /value/ is a term that cannot (or does not) take any more
---   evaluation steps on its own.
-data Value where
-  -- | The unit value.
-  VUnit :: Value
-  -- | An integer.
-  VInt :: Integer -> Value
-  -- | Literal text.
-  VText :: Text -> Value
-  -- | A direction.
-  VDir :: Direction -> Value
-  -- | A boolean.
-  VBool :: Bool -> Value
-  -- | A reference to a robot.
-  VRobot :: Int -> Value
-  -- | An injection into a sum type.  False = left, True = right.
-  VInj :: Bool -> Value -> Value
-  -- | A pair.
-  VPair :: Value -> Value -> Value
-  -- | A /closure/, representing a lambda term along with an
-  --   environment containing bindings for any free variables in the
-  --   body of the lambda.
-  VClo :: Var -> Term -> Env -> Value
-  -- | An application of a constant to some value arguments,
-  --   potentially waiting for more arguments.  If a constant
-  --   application is fully saturated (as defined by its 'arity'),
-  --   whether it is a value or not depends on whether or not it
-  --   represents a command (as defined by 'isCmd').  If a command
-  --   (e.g. 'Build'), it is a value, and awaits an 'Swarm.Game.CESK.FExec' frame
-  --   which will cause it to execute.  Otherwise (e.g. 'If'), it is
-  --   not a value, and will immediately reduce.
-  VCApp :: Const -> [Value] -> Value
-  -- | A definition, which does not take effect until executed.
-  --   The @Bool@ indicates whether the definition is recursive.
-  VDef :: Bool -> Var -> Term -> Env -> Value
-  -- | The result of a command, consisting of the result of the
-  --   command as well as an environment of bindings from 'TDef'
-  --   commands.
-  VResult :: Value -> Env -> Value
-  -- | An unevaluated bind expression, waiting to be executed, of the
-  --   form /i.e./ @c1 ; c2@ or @x <- c1; c2@.  We also store an 'Env'
-  --   in which to interpret the commands.
-  VBind :: Maybe Var -> Term -> Term -> Env -> Value
-  -- | A (non-recursive) delayed term, along with its environment. If
-  --   a term would otherwise be evaluated but we don't want it to be
-  --   (/e.g./ as in the case of arguments to an 'if', or a recursive
-  --   binding), we can stick a 'TDelay' on it, which turns it into a
-  --   value.  Delayed terms won't be evaluated until 'Force' is
-  --   applied to them.
-  VDelay :: Term -> Env -> Value
-  -- | A reference to a memory cell in the store.
-  VRef :: Int -> Value
-  deriving (Eq, Show, Generic, FromJSON, ToJSON)
-
--- | Pretty-print a value.
-prettyValue :: Value -> Text
-prettyValue = prettyText . valueToTerm
-
--- | Inject a value back into a term.
-valueToTerm :: Value -> Term
-valueToTerm VUnit = TUnit
-valueToTerm (VInt n) = TInt n
-valueToTerm (VText s) = TText s
-valueToTerm (VDir d) = TDir d
-valueToTerm (VBool b) = TBool b
-valueToTerm (VRobot r) = TRobot r
-valueToTerm (VInj s v) = TApp (TConst (bool Inl Inr s)) (valueToTerm v)
-valueToTerm (VPair v1 v2) = TPair (valueToTerm v1) (valueToTerm v2)
-valueToTerm (VClo x t e) =
-  M.foldrWithKey
-    (\y v -> TLet False y Nothing (valueToTerm v))
-    (TLam x Nothing t)
-    (M.restrictKeys (unCtx e) (S.delete x (setOf fv t)))
-valueToTerm (VCApp c vs) = foldl' TApp (TConst c) (reverse (map valueToTerm vs))
-valueToTerm (VDef r x t _) = TDef r x Nothing t
-valueToTerm (VResult v _) = valueToTerm v
-valueToTerm (VBind mx c1 c2 _) = TBind mx c1 c2
-valueToTerm (VDelay t _) = TDelay SimpleDelay t
-valueToTerm (VRef n) = TRef n
-
--- | An environment is a mapping from variable names to values.
-type Env = Ctx Value
diff --git a/src/Swarm/Game/World.hs b/src/Swarm/Game/World.hs
--- a/src/Swarm/Game/World.hs
+++ b/src/Swarm/Game/World.hs
@@ -42,6 +42,9 @@
   lookupTerrainM,
   lookupEntityM,
   updateM,
+
+  -- ** Runtime updates
+  WorldUpdate (..),
 ) where
 
 import Control.Algebra (Has)
@@ -53,11 +56,12 @@
 import Data.Array.Unboxed qualified as U
 import Data.Bits
 import Data.Foldable (foldl')
-import Data.Int (Int64)
+import Data.Int (Int32)
 import Data.Map.Strict qualified as M
+import Data.Yaml (FromJSON, ToJSON)
 import GHC.Generics (Generic)
-import Linear
 import Swarm.Util
+import Swarm.Util.Location
 import Prelude hiding (lookup)
 
 ------------------------------------------------------------
@@ -65,21 +69,25 @@
 ------------------------------------------------------------
 
 -- | World coordinates use (row,column) format, with the row
---   increasing as we move down the screen.  This format plays nicely
---   with drawing the screen.
-newtype Coords = Coords {unCoords :: (Int64, Int64)}
+--   increasing as we move down the screen.  We use this format for
+--   indexing worlds internally, since it plays nicely with things
+--   like drawing the screen, and reading maps from configuration
+--   files. The 'locToCoords' and 'coordsToLoc' functions convert back
+--   and forth between this type and 'Location', which is used when
+--   presenting coordinates externally to the player.
+newtype Coords = Coords {unCoords :: (Int32, Int32)}
   deriving (Eq, Ord, Show, Ix, Generic)
 
 instance Rewrapped Coords t
 instance Wrapped Coords
 
--- | Convert an (x,y) location to a 'Coords' value.
-locToCoords :: V2 Int64 -> Coords
-locToCoords (V2 x y) = Coords (-y, x)
+-- | Convert an external (x,y) location to an internal 'Coords' value.
+locToCoords :: Location -> Coords
+locToCoords (Location x y) = Coords (-y, x)
 
--- | Convert 'Coords' to an (x,y) location.
-coordsToLoc :: Coords -> V2 Int64
-coordsToLoc (Coords (r, c)) = V2 c (-r)
+-- | Convert an internal 'Coords' value to an external (x,y) location.
+coordsToLoc :: Coords -> Location
+coordsToLoc (Coords (r, c)) = Location c (-r)
 
 ------------------------------------------------------------
 -- World function
@@ -95,7 +103,7 @@
 
 -- | Create a world function from a finite array of specified cells
 --   plus a single default cell to use everywhere else.
-worldFunFromArray :: Array (Int64, Int64) (t, Maybe e) -> (t, Maybe e) -> WorldFun t e
+worldFunFromArray :: Array (Int32, Int32) (t, Maybe e) -> (t, Maybe e) -> WorldFun t e
 worldFunFromArray arr def = WF $ \(Coords (r, c)) ->
   if inRange bnds (r, c)
     then arr ! (r, c)
@@ -120,7 +128,7 @@
 
 -- | The number consisting of 'tileBits' many 1 bits.  We can use this
 --   to mask out the tile offset of a coordinate.
-tileMask :: Int64
+tileMask :: Int32
 tileMask = (1 `shiftL` tileBits) - 1
 
 -- | If we think of the world as a grid of /tiles/, we can assign each
@@ -274,3 +282,18 @@
    where
     tileCorner = tileOrigin tc
     (terrain, entities) = unzip $ map (runWF f . plusOffset tileCorner) (range tileBounds)
+
+-- ------------------------------------------------------------------
+-- Runtime world update
+-- ------------------------------------------------------------------
+
+-- | Update world in an inspectable way.
+--
+-- This type is used for changes by e.g. the drill command at later
+-- tick. Using ADT allows us to serialize and inspect the updates.
+data WorldUpdate e = ReplaceEntity
+  { updatedLoc :: Location
+  , originalEntity :: e
+  , newEntity :: Maybe e
+  }
+  deriving (Eq, Ord, Show, Generic, FromJSON, ToJSON)
diff --git a/src/Swarm/Game/WorldGen.hs b/src/Swarm/Game/WorldGen.hs
--- a/src/Swarm/Game/WorldGen.hs
+++ b/src/Swarm/Game/WorldGen.hs
@@ -18,7 +18,7 @@
 import Data.ByteString (ByteString)
 import Data.Enumeration
 import Data.Hash.Murmur
-import Data.Int (Int64)
+import Data.Int (Int32)
 import Data.List (find)
 import Data.Maybe (fromMaybe, mapMaybe)
 import Data.Set qualified as S
@@ -102,9 +102,9 @@
       | sample ix cl0 > 0.5 = (StoneT, Just "mountain")
       | h `mod` 30 == 0 = (StoneT, Just "boulder")
       | sample ix cl0 > 0 =
-        case h `mod` 30 of
-          1 -> (DirtT, Just "LaTeX")
-          _ -> (DirtT, Just "tree")
+          case h `mod` 30 of
+            1 -> (DirtT, Just "LaTeX")
+            _ -> (DirtT, Just "tree")
       | otherwise = (GrassT, Nothing)
     genBiome Small Hard Natural
       | h `mod` 100 == 0 = (StoneT, Just "lodestone")
@@ -153,7 +153,7 @@
 
 -- | Create a world function from a finite array of specified cells
 --   plus a seed to randomly generate the rest.
-testWorld2FromArray :: EntityMap -> Array (Int64, Int64) (TerrainType, Maybe Entity) -> Seed -> WorldFun TerrainType Entity
+testWorld2FromArray :: EntityMap -> Array (Int32, Int32) (TerrainType, Maybe Entity) -> Seed -> WorldFun TerrainType Entity
 testWorld2FromArray em arr seed = WF $ \co@(Coords (r, c)) ->
   if inRange bnds (r, c)
     then arr ! (r, c)
@@ -167,7 +167,7 @@
 findOffset :: Integer -> ((Coords -> (t, Maybe e)) -> Bool) -> WorldFun t e -> WorldFun t e
 findOffset skip isGood (WF f) = WF f'
  where
-  offset :: Enumeration Int64
+  offset :: Enumeration Int32
   offset = fromIntegral . (skip *) <$> int
 
   f' =
diff --git a/src/Swarm/Language/Capability.hs b/src/Swarm/Language/Capability.hs
--- a/src/Swarm/Language/Capability.hs
+++ b/src/Swarm/Language/Capability.hs
@@ -8,7 +8,7 @@
 -- Capabilities needed to evaluate and execute programs.  Language
 -- constructs or commands require certain capabilities, and in turn
 -- capabilities are provided by various devices.  A robot must have an
--- appropriate device installed in order to make use of each language
+-- appropriate device equipped in order to make use of each language
 -- construct or command.
 module Swarm.Language.Capability (
   Capability (..),
@@ -51,8 +51,10 @@
     CPlace
   | -- | Execute the 'Give' command
     CGive
-  | -- | Execute the 'Install' command
-    CInstall
+  | -- | Execute the 'Equip' command
+    CEquip
+  | -- | Execute the 'Unequip' command
+    CUnequip
   | -- | Execute the 'Make' command
     CMake
   | -- | Execute the 'Count' command
@@ -67,7 +69,7 @@
     CSenseloc
   | -- | Execute the 'Blocked' command
     CSensefront
-  | -- | Execute the 'Ishere' command
+  | -- | Execute the 'Ishere' and 'Isempty' commands
     CSensehere
   | -- | Execute the 'Scan' command
     CScan
@@ -83,6 +85,8 @@
     CLog
   | -- | Manipulate text values
     CText
+  | -- | Convert between characters/text and Unicode values
+    CCode
   | -- | Don't drown in liquid
     CFloat
   | -- | Evaluate conditional expressions
@@ -103,6 +107,8 @@
     CRecursion
   | -- | Execute the 'Reprogram' command
     CReprogram
+  | -- | Execute the `meet` and `meetAll` commands.
+    CMeet
   | -- | Capability to introspect and see its own name
     CWhoami
   | -- | Capability to set its own name
@@ -157,7 +163,7 @@
   Undefined -> Nothing
   Fail -> Nothing
   Has -> Nothing
-  Installed -> Nothing
+  Equipped -> Nothing
   -- speaking is natural to robots (unlike listening)
   Say -> Nothing
   -- TODO: #495
@@ -175,17 +181,21 @@
   Harvest -> Just CHarvest
   Place -> Just CPlace
   Give -> Just CGive
-  Install -> Just CInstall
+  Equip -> Just CEquip
+  Unequip -> Just CUnequip
   Make -> Just CMake
   Count -> Just CCount
   If -> Just CCond
   Blocked -> Just CSensefront
   Scan -> Just CScan
   Ishere -> Just CSensehere
+  Isempty -> Just CSensehere
   Upload -> Just CScan
   Build -> Just CBuild
   Salvage -> Just CSalvage
   Reprogram -> Just CReprogram
+  Meet -> Just CMeet
+  MeetAll -> Just CMeet
   Drill -> Just CDrill
   Neg -> Just CArith
   Add -> Just CArith
@@ -199,12 +209,16 @@
   Atomic -> Just CAtomic
   Time -> Just CTime
   Wait -> Just CTime
+  Whereami -> Just CSenseloc
+  Heading -> Just COrient
   -- ----------------------------------------------------------------
   -- Text operations
   Format -> Just CText
   Concat -> Just CText
   Split -> Just CText
   Chars -> Just CText
+  CharAt -> Just CCode
+  ToChar -> Just CCode
   -- ----------------------------------------------------------------
   -- Some God-like abilities.
   As -> Just CGod
@@ -234,7 +248,7 @@
   Case -> Just CSum
   Fst -> Just CProd
   Snd -> Just CProd
-  -- XXX pair syntax should require CProd too
+  -- TODO: #563 pair syntax (1,2,3...) should require CProd too
 
   -- ----------------------------------------------------------------
   -- Some additional straightforward ones, which however currently
@@ -242,10 +256,9 @@
   -- which conveys their capability. TODO: #26
   Teleport -> Just CTeleport -- Some space-time machine like Tardis?
   Appear -> Just CAppear -- paint?
-  Whereami -> Just CSenseloc -- GPS?
   Random -> Just CRandom -- randomness device (with bitcoins)?
   -- ----------------------------------------------------------------
   -- Some more constants which *ought* to have their own capability but
-  -- currently don't. TODO: #26
-  View -> Nothing -- XXX this should also require something.
+  -- currently don't.
+  View -> Nothing -- TODO: #17 should require equipping an antenna
   Knows -> Nothing
diff --git a/src/Swarm/Language/Context.hs b/src/Swarm/Language/Context.hs
--- a/src/Swarm/Language/Context.hs
+++ b/src/Swarm/Language/Context.hs
@@ -1,4 +1,3 @@
-{-# LANGUAGE DeriveAnyClass #-}
 {-# LANGUAGE DeriveDataTypeable #-}
 
 -- |
diff --git a/src/Swarm/Language/Elaborate.hs b/src/Swarm/Language/Elaborate.hs
--- a/src/Swarm/Language/Elaborate.hs
+++ b/src/Swarm/Language/Elaborate.hs
@@ -1,3 +1,5 @@
+{-# LANGUAGE OverloadedStrings #-}
+
 -- |
 -- Module      :  Swarm.Language.Elaborate
 -- Copyright   :  Brent Yorgey
@@ -8,40 +10,51 @@
 -- Term elaboration which happens after type checking.
 module Swarm.Language.Elaborate where
 
-import Control.Lens (transform, (%~))
+import Control.Lens (transform, (%~), (^.))
 import Swarm.Language.Syntax
+import Swarm.Language.Types
 
 -- | Perform some elaboration / rewriting on a fully type-annotated
---   term, given its top-level type.  This currently performs such
---   operations as rewriting @if@ expressions and recursive let
---   expressions to use laziness appropriately.  In theory it could
---   also perform rewriting for overloaded constants depending on the
---   actual type they are used at, but currently that sort of thing
---   tends to make type inference fall over.
-elaborate :: Term -> Term
+--   term.  This currently performs such operations as rewriting @if@
+--   expressions and recursive let expressions to use laziness
+--   appropriately.  In theory it could also perform rewriting for
+--   overloaded constants depending on the actual type they are used
+--   at, but currently that sort of thing tends to make type inference
+--   fall over.
+elaborate :: Syntax' Polytype -> Syntax' Polytype
 elaborate =
   -- Wrap all *free* variables in 'Force'.  Free variables must be
   -- referring to a previous definition, which are all wrapped in
   -- 'TDelay'.
-  (fvT %~ TApp (TConst Force))
+  (freeVarsS %~ \s -> Syntax' (s ^. sLoc) (SApp sForce s) (s ^. sType))
     -- Now do additional rewriting on all subterms.
     . transform rewrite
  where
-  -- For recursive let bindings, rewrite any occurrences of x to
-  -- (force x).  When interpreting t1, we will put a binding (x |->
-  -- delay t1) in the context.
-  rewrite (TLet True x ty t1 t2) = TLet True x ty (wrapForce x t1) (wrapForce x t2)
-  -- Rewrite any recursive occurrences of x inside t1 to (force x).
-  -- When a TDef is encountered at runtime its body will immediately
-  -- be wrapped in a VDelay. However, to make this work we also need
-  -- to wrap all free variables in any term with 'force' --- since
-  -- any such variables must in fact refer to things previously
-  -- bound by 'def'.
-  rewrite (TDef True x ty t1) = TDef True x ty (mapFree1 x (TApp (TConst Force)) t1)
-  -- Rewrite @f $ x@ to @f x@.
-  rewrite (TApp (TApp (TConst AppF) r) l) = TApp r l
-  -- Leave any other subterms alone.
-  rewrite t = t
+  rewrite :: Syntax' Polytype -> Syntax' Polytype
+  rewrite (Syntax' l t ty) = Syntax' l (rewriteTerm t) ty
 
-wrapForce :: Var -> Term -> Term
-wrapForce x = mapFree1 x (TApp (TConst Force))
+  rewriteTerm :: Term' Polytype -> Term' Polytype
+  rewriteTerm = \case
+    -- For recursive let bindings, rewrite any occurrences of x to
+    -- (force x).  When interpreting t1, we will put a binding (x |->
+    -- delay t1) in the context.
+    SLet True x ty t1 t2 -> SLet True x ty (wrapForce (lvVar x) t1) (wrapForce (lvVar x) t2)
+    -- Rewrite any recursive occurrences of x inside t1 to (force x).
+    -- When a TDef is encountered at runtime its body will immediately
+    -- be wrapped in a VDelay. However, to make this work we also need
+    -- to wrap all free variables in any term with 'force' --- since
+    -- any such variables must in fact refer to things previously
+    -- bound by 'def'.
+    SDef True x ty t1 -> SDef True x ty (wrapForce (lvVar x) t1)
+    -- Rewrite @f $ x@ to @f x@.
+    SApp (Syntax' _ (SApp (Syntax' _ (TConst AppF) _) l) _) r -> SApp l r
+    -- Leave any other subterms alone.
+    t -> t
+
+wrapForce :: Var -> Syntax' Polytype -> Syntax' Polytype
+wrapForce x = mapFreeS x (\s@(Syntax' l _ ty) -> Syntax' l (SApp sForce s) ty)
+
+-- Note, TyUnit is not the right type, but I don't want to bother
+
+sForce :: Syntax' Polytype
+sForce = Syntax' NoLoc (TConst Force) (Forall ["a"] (TyDelay (TyVar "a") :->: TyVar "a"))
diff --git a/src/Swarm/Language/LSP.hs b/src/Swarm/Language/LSP.hs
--- a/src/Swarm/Language/LSP.hs
+++ b/src/Swarm/Language/LSP.hs
@@ -14,15 +14,17 @@
 import Control.Lens (to, (^.))
 import Control.Monad (void)
 import Control.Monad.IO.Class
-import Data.Foldable (forM_)
-import Data.Maybe (fromMaybe)
+import Data.Maybe (fromMaybe, mapMaybe)
 import Data.Text (Text)
 import Data.Text.IO qualified as Text
 import Language.LSP.Diagnostics
 import Language.LSP.Server
+import Language.LSP.Types (Hover (Hover))
 import Language.LSP.Types qualified as J
 import Language.LSP.Types.Lens qualified as J
 import Language.LSP.VFS
+import Swarm.Language.LSP.Hover qualified as H
+import Swarm.Language.LSP.VarUsage qualified as VU
 import Swarm.Language.Parse
 import Swarm.Language.Pipeline
 import System.IO (stderr)
@@ -40,7 +42,7 @@
         , interpretHandler = \env -> Iso (runLspT env) liftIO
         , options =
             defaultOptions
-              { -- set sync options to get DidSave event
+              { -- set sync options to get DidSave event, as well as Open and Close events.
                 textDocumentSync =
                   Just
                     ( J.TextDocumentSyncOptions
@@ -58,39 +60,75 @@
   -- handler is called for each key-stroke.
   syncKind = J.TdSyncFull
 
+diagnosticSourcePrefix :: Text
+diagnosticSourcePrefix = "swarm-lsp"
+
 debug :: MonadIO m => Text -> m ()
 debug msg = liftIO $ Text.hPutStrLn stderr $ "[swarm-lsp] " <> msg
 
 validateSwarmCode :: J.NormalizedUri -> J.TextDocumentVersion -> Text -> LspM () ()
 validateSwarmCode doc version content = do
   -- debug $ "Validating: " <> from (show doc) <> " ( " <> content <> ")"
-  flushDiagnosticsBySource 0 (Just "swarm-lsp")
-  let err = case readTerm' content of
-        Right Nothing -> Nothing
-        Right (Just term) -> case processParsedTerm' mempty mempty term of
-          Right _ -> Nothing
-          Left e -> Just $ showTypeErrorPos content e
-        Left e -> Just $ showErrorPos e
+
+  -- FIXME: #1040 With this call to flushDiagnosticsBySource in place, the warnings
+  -- in other buffers (editor tabs) end up getting cleared when switching between
+  -- (focusing on) other buffers in VS Code.
+  -- However, getting rid of this seems to break error highlighting.
+  flushDiagnosticsBySource 0 (Just diagnosticSourcePrefix)
+
+  let (parsingErrs, unusedVarWarnings) = case readTerm' content of
+        Right Nothing -> ([], [])
+        Right (Just term) -> (parsingErrors, unusedWarnings)
+         where
+          VU.Usage _ problems = VU.getUsage mempty term
+          unusedWarnings = mapMaybe (VU.toErrPos content) problems
+
+          parsingErrors = case processParsedTerm' mempty mempty term of
+            Right _ -> []
+            Left e -> pure $ showTypeErrorPos content e
+        Left e -> (pure $ showErrorPos e, [])
   -- debug $ "-> " <> from (show err)
-  forM_ err sendDiagnostic
+
+  publishDiags $
+    map makeUnusedVarDiagnostic unusedVarWarnings
+
+  -- NOTE: "publishDiags" keeps only one diagnostic at a
+  -- time (the most recent) so we make sure the errors are
+  -- issued last (after any warnings).
+  -- Note that it does not achieve the desired effect to simply
+  -- concatenate the two diagnostic lists into a single
+  -- publishDiagnostics function call (regardless of the order
+  -- of the lists).
+  publishDiags $
+    map makeParseErrorDiagnostic parsingErrs
  where
-  sendDiagnostic :: ((Int, Int), (Int, Int), Text) -> LspM () ()
-  sendDiagnostic ((startLine, startCol), (endLine, endCol), msg) = do
-    let diags =
-          [ J.Diagnostic
-              ( J.Range
-                  (J.Position (fromIntegral startLine) (fromIntegral startCol))
-                  (J.Position (fromIntegral endLine) (fromIntegral endCol))
-              )
-              (Just J.DsWarning) -- severity
-              Nothing -- code
-              (Just "swarm-lsp") -- source
-              msg
-              Nothing -- tags
-              (Just (J.List []))
-          ]
-    publishDiagnostics 1 doc version (partitionBySource diags)
+  publishDiags :: [J.Diagnostic] -> LspM () ()
+  publishDiags = publishDiagnostics 1 doc version . partitionBySource
 
+  makeUnusedVarDiagnostic :: (J.Range, Text) -> J.Diagnostic
+  makeUnusedVarDiagnostic (range, msg) =
+    J.Diagnostic
+      range
+      (Just J.DsWarning) -- severity
+      Nothing -- code
+      (Just diagnosticSourcePrefix) -- source
+      msg
+      (Just (J.List [J.DtUnnecessary])) -- tags
+      Nothing -- related source code info
+  makeParseErrorDiagnostic :: ((Int, Int), (Int, Int), Text) -> J.Diagnostic
+  makeParseErrorDiagnostic ((startLine, startCol), (endLine, endCol), msg) =
+    J.Diagnostic
+      ( J.Range
+          (J.Position (fromIntegral startLine) (fromIntegral startCol))
+          (J.Position (fromIntegral endLine) (fromIntegral endCol))
+      )
+      (Just J.DsError) -- severity
+      Nothing -- code
+      (Just diagnosticSourcePrefix) -- source
+      msg
+      Nothing -- tags
+      (Just (J.List []))
+
 handlers :: Handlers (LspM ())
 handlers =
   mconcat
@@ -111,4 +149,13 @@
           Just vf@(VirtualFile _ version _rope) -> do
             validateSwarmCode doc (Just $ fromIntegral version) (virtualFileText vf)
           _ -> debug $ "No virtual file found for: " <> from (show msg)
+    , requestHandler J.STextDocumentHover $ \req responder -> do
+        let doc = req ^. J.params . J.textDocument . J.uri . to J.toNormalizedUri
+            pos = req ^. J.params . J.position
+        mdoc <- getVirtualFile doc
+        let maybeHover = do
+              vf <- mdoc
+              (markdownText, maybeRange) <- H.showHoverInfo doc Nothing pos vf
+              return $ Hover (J.HoverContents $ J.MarkupContent J.MkMarkdown markdownText) maybeRange
+        responder $ Right maybeHover
     ]
diff --git a/src/Swarm/Language/LSP/Hover.hs b/src/Swarm/Language/LSP/Hover.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Language/LSP/Hover.hs
@@ -0,0 +1,260 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Swarm.Language.LSP.Hover (
+  showHoverInfo,
+
+  -- * Documentation rendering
+  renderDoc,
+  treeToMarkdown,
+
+  -- * Finding source location
+  narrowToPosition,
+
+  -- * Explaining source position
+  explain,
+) where
+
+import Control.Applicative ((<|>))
+import Control.Lens ((^.))
+import Control.Monad (guard, void)
+import Data.Graph
+import Data.List.NonEmpty (NonEmpty (..))
+import Data.Maybe (fromMaybe)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.Utf16.Rope qualified as R
+import Language.LSP.Types qualified as J
+import Language.LSP.VFS
+import Swarm.Language.Context as Ctx
+import Swarm.Language.Module (Module (..))
+import Swarm.Language.Parse (readTerm', unTuple)
+import Swarm.Language.Pipeline (ProcessedTerm (..), processParsedTerm)
+import Swarm.Language.Pretty (prettyText)
+import Swarm.Language.Syntax
+import Swarm.Language.Typecheck (inferConst)
+import Swarm.Language.Types
+import Swarm.Util qualified as U
+
+withinBound :: Int -> SrcLoc -> Bool
+withinBound pos (SrcLoc s e) = pos >= s && pos < e
+withinBound _ NoLoc = False
+
+ropeToLspPosition :: R.Position -> J.Position
+ropeToLspPosition (R.Position l c) =
+  J.Position (fromIntegral l) (fromIntegral c)
+
+lspToRopePosition :: J.Position -> R.Position
+lspToRopePosition (J.Position myLine myCol) =
+  R.Position (fromIntegral myLine) (fromIntegral myCol)
+
+showHoverInfo ::
+  J.NormalizedUri ->
+  J.TextDocumentVersion ->
+  J.Position ->
+  VirtualFile ->
+  Maybe (Text, Maybe J.Range)
+showHoverInfo _ _ p vf@(VirtualFile _ _ myRope) =
+  case readTerm' content of
+    Left _ -> Nothing
+    Right Nothing -> Nothing
+    Right (Just stx) -> Just $ case processParsedTerm stx of
+      Left _e ->
+        let found@(Syntax foundSloc _) = narrowToPosition stx $ fromIntegral absolutePos
+            finalPos = posToRange myRope foundSloc
+         in (,finalPos) . treeToMarkdown 0 $ explain found
+      Right (ProcessedTerm modul _req _reqCtx) ->
+        let found@(Syntax' foundSloc _ _) = narrowToPosition (moduleAST modul) $ fromIntegral absolutePos
+            finalPos = posToRange myRope foundSloc
+         in (,finalPos) . treeToMarkdown 0 $ explain found
+ where
+  content = virtualFileText vf
+  absolutePos =
+    maybe 0 (R.length . fst) $
+      R.splitAtPosition (lspToRopePosition p) myRope
+
+posToRange :: R.Rope -> SrcLoc -> Maybe J.Range
+posToRange myRope foundSloc = do
+  (s, e) <- case foundSloc of
+    SrcLoc s e -> Just (s, e)
+    _ -> Nothing
+  (startRope, _) <- R.splitAt (fromIntegral s) myRope
+  (endRope, _) <- R.splitAt (fromIntegral e) myRope
+  return $
+    J.Range
+      (ropeToLspPosition $ R.lengthAsPosition startRope)
+      (ropeToLspPosition $ R.lengthAsPosition endRope)
+
+descend ::
+  ExplainableType ty =>
+  -- | position
+  Int ->
+  -- | next element to inspect
+  Syntax' ty ->
+  Maybe (Syntax' ty)
+descend pos s1@(Syntax' l1 _ _) = do
+  guard $ withinBound pos l1
+  return $ narrowToPosition s1 pos
+
+-- | Find the most specific term for a given
+-- position within the code.
+narrowToPosition ::
+  ExplainableType ty =>
+  -- | parent term
+  Syntax' ty ->
+  -- | absolute offset within the file
+  Int ->
+  Syntax' ty
+narrowToPosition s0@(Syntax' _ t ty) pos = fromMaybe s0 $ case t of
+  SLam lv _ s -> d (locVarToSyntax' lv $ getInnerType ty) <|> d s
+  SApp s1 s2 -> d s1 <|> d s2
+  SLet _ lv _ s1@(Syntax' _ _ lty) s2 -> d (locVarToSyntax' lv lty) <|> d s1 <|> d s2
+  SDef _ lv _ s@(Syntax' _ _ lty) -> d (locVarToSyntax' lv lty) <|> d s
+  SBind mlv s1@(Syntax' _ _ lty) s2 -> (mlv >>= d . flip locVarToSyntax' (getInnerType lty)) <|> d s1 <|> d s2
+  SPair s1 s2 -> d s1 <|> d s2
+  SDelay _ s -> d s
+  -- atoms - return their position and end recursion
+  TUnit -> Nothing
+  TConst {} -> Nothing
+  TDir {} -> Nothing
+  TInt {} -> Nothing
+  TText {} -> Nothing
+  TBool {} -> Nothing
+  TVar {} -> Nothing
+  TRequire {} -> Nothing
+  TRequireDevice {} -> Nothing
+  -- these should not show up in surface language
+  TRef {} -> Nothing
+  TRobot {} -> Nothing
+  TAntiInt {} -> Nothing
+  TAntiText {} -> Nothing
+ where
+  d = descend pos
+
+renderDoc :: Int -> Text -> Text
+renderDoc d t
+  | d == 0 = t
+  | otherwise = T.drop 2 . indent (max 0 (4 * (d - 1)) + 2) $ "* " <> t
+ where
+  indent x = T.unlines . map (T.replicate x " " <>) . T.lines
+
+treeToMarkdown :: Int -> Tree Text -> Text
+treeToMarkdown d (Node t children) =
+  T.unlines $ renderDoc d t : map (treeToMarkdown $ d + 1) children
+
+class Show t => ExplainableType t where
+  -- | Pretty print the type.
+  prettyType :: t -> Text
+
+  -- | Strip the type of its outermost layer.
+  --
+  -- This allows us to strip lambda or command type
+  -- and get the type of the bound variable.
+  getInnerType :: t -> t
+
+  -- | Check if this type is same as the given 'Polytype'.
+  --
+  -- We use it to not print same type twice (e.g. inferred and generic).
+  eq :: t -> Polytype -> Bool
+
+instance ExplainableType () where
+  prettyType = const "?"
+  getInnerType = id
+  eq _ _ = False
+
+instance ExplainableType Polytype where
+  prettyType = prettyText
+  getInnerType = fmap $ \case
+    (l :->: _r) -> l
+    (TyCmd t) -> t
+    t -> t
+  eq = (==)
+
+explain :: ExplainableType ty => Syntax' ty -> Tree Text
+explain trm = case trm ^. sTerm of
+  TUnit -> literal "The unit value."
+  TConst c -> literal . constGenSig c $ briefDoc (constDoc $ constInfo c)
+  TDir {} -> literal "A direction literal."
+  TInt {} -> literal "An integer literal."
+  TText {} -> literal "A text literal."
+  TBool {} -> literal "A boolean literal."
+  TVar v -> pure $ typeSignature v ty ""
+  -- special forms (function application will show for `$`, but really should be rare)
+  SApp {} -> explainFunction trm
+  TRequireDevice {} -> pure "Require a specific device to be equipped."
+  TRequire {} -> pure "Require a certain number of an entity."
+  -- definition or bindings
+  SLet isRecursive var mTypeAnn rhs _b -> pure $ explainDefinition False isRecursive var (rhs ^. sType) mTypeAnn
+  SDef isRecursive var mTypeAnn rhs -> pure $ explainDefinition True isRecursive var (rhs ^. sType) mTypeAnn
+  SLam (LV _s v) _mType _syn ->
+    pure $
+      typeSignature v ty $
+        "A lambda expression binding the variable " <> U.bquote v <> "."
+  SBind mv rhs _cmds ->
+    pure $
+      typeSignature (maybe "__rhs" lvVar mv) (getInnerType $ rhs ^. sType) $
+        "A monadic bind for commands" <> maybe "." (\(LV _s v) -> ", that binds variable " <> U.bquote v <> ".") mv
+  -- composite types
+  SPair {} ->
+    Node
+      (typeSignature "_" ty "A tuple consisting of:")
+      (explain <$> unTuple trm)
+  SDelay {} ->
+    pure . T.unlines $
+      [ "Delay evaluation of a term, written `{...}`."
+      , ""
+      , "Swarm is an eager language, but in some cases (e.g. for `if` statements and recursive bindings) we need to delay evaluation."
+      , ""
+      , "The counterpart to `{...}` is `force`:"
+      , "```"
+      , "force {t} = t"
+      , "```"
+      ]
+  -- internal syntax that should not actually show in hover
+  TRef {} -> internal "A memory reference."
+  TAntiInt {} -> internal "An antiquoted Haskell variable name of type Integer."
+  TAntiText {} -> internal "An antiquoted Haskell variable name of type Text."
+  TRobot {} -> internal "A robot reference."
+ where
+  ty = trm ^. sType
+  literal = pure . typeSignature (prettyText . void $ trm ^. sTerm) ty
+  internal description = literal $ description <> "\n**These should never show up in surface syntax.**"
+  constGenSig c =
+    let ity = inferConst c
+     in if ty `eq` ity then id else typeSignature (prettyText c) ity
+
+-- | Helper function to explain function application.
+--
+-- Note that 'Force' is often inserted internally, so
+-- if it shows up here we drop it.
+explainFunction :: ExplainableType ty => Syntax' ty -> Tree Text
+explainFunction s =
+  case unfoldApps s of
+    (Syntax' _ (TConst Force) _ :| [innerT]) -> explain innerT
+    (Syntax' _ (TConst Force) _ :| f : params) -> explainF f params
+    (f :| params) -> explainF f params
+ where
+  explainF f params =
+    Node
+      "Function application of:"
+      [ explain f
+      , Node
+          "with parameters:"
+          (map explain params)
+      ]
+
+explainDefinition :: ExplainableType ty => Bool -> Bool -> LocVar -> ty -> Maybe Polytype -> Text
+explainDefinition isDef isRecursive (LV _s var) ty maybeTypeAnnotation =
+  typeSignature var ty $
+    T.unwords
+      [ "A"
+      , (if isRecursive then "" else "non-") <> "recursive"
+      , if isDef then "definition" else "let"
+      , "expression"
+      , if null maybeTypeAnnotation then "without" else "with"
+      , "a type annotation on the variable."
+      ]
+
+typeSignature :: ExplainableType ty => Var -> ty -> Text -> Text
+typeSignature v typ body = T.unlines ["```", short, "```", body]
+ where
+  short = v <> ": " <> prettyType typ
diff --git a/src/Swarm/Language/LSP/VarUsage.hs b/src/Swarm/Language/LSP/VarUsage.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Language/LSP/VarUsage.hs
@@ -0,0 +1,108 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Swarm.Language.LSP.VarUsage where
+
+import Control.Monad (guard)
+import Data.List.NonEmpty (NonEmpty (..))
+import Data.Map (Map)
+import Data.Map qualified as M
+import Data.Set (Set)
+import Data.Set qualified as S
+import Data.Text (Text)
+import Data.Text qualified as T
+import Language.LSP.Types qualified as J
+import Swarm.Language.Parse qualified as P
+import Swarm.Language.Syntax
+import Swarm.Util qualified as U
+
+data BindingType
+  = Lambda
+  | Let
+  | Bind
+  deriving (Eq, Show)
+
+data VarUsage = VarUsage LocVar BindingType
+
+type BindingSites = Map Var (NonEmpty SrcLoc)
+
+data Usage = Usage
+  { usages :: Set LocVar
+  -- ^ Variable references
+  , problems :: [VarUsage]
+  -- ^ Variable declarations without any references
+  }
+
+instance Semigroup Usage where
+  Usage y1 z1 <> Usage y2 z2 =
+    Usage
+      (y1 <> y2)
+      (z1 <> z2)
+
+instance Monoid Usage where
+  mempty = Usage mempty mempty
+
+toErrPos :: Text -> VarUsage -> Maybe (J.Range, Text)
+toErrPos code (VarUsage (LV loc v) scope) = do
+  -- A leading underscore will suppress the unused variable warning
+  guard $ not $ "_" `T.isPrefixOf` v
+  rangePair <- case loc of
+    SrcLoc s e -> Just (s, e)
+    _ -> Nothing
+  let (start, end) = P.getLocRange code rangePair
+      ((startLine, startCol), (endLine, endCol)) = (minusOne start, minusOne end)
+      range =
+        J.Range
+          (J.Position (fromIntegral startLine) (fromIntegral startCol))
+          (J.Position (fromIntegral endLine) (fromIntegral endCol))
+  return (range, txt)
+ where
+  txt =
+    T.unwords
+      [ "Unused variable"
+      , U.quote v
+      , "in"
+      , T.pack $ show scope
+      , "expression"
+      ]
+  minusOne (x, y) = (x - 1, y - 1)
+
+-- | Descends the syntax tree rooted at a variable declaration,
+-- accumulating variable references.
+-- Generates a "problem" if an associated variable reference
+-- is not encountered in the subtree for this declaration.
+checkOccurrences ::
+  BindingSites ->
+  LocVar ->
+  BindingType ->
+  [Syntax] ->
+  Usage
+checkOccurrences bindings lv@(LV loc v) declType childSyntaxes =
+  Usage childUsages $ missing <> deeperMissing
+ where
+  deeperBindings = M.insertWith (<>) v (pure loc) bindings
+  Usage childUsages deeperMissing = mconcat $ map (getUsage deeperBindings) childSyntaxes
+  missing = [VarUsage lv declType | lv `S.notMember` childUsages]
+
+-- | Build up the bindings map as a function argument as
+-- we descend into the syntax tree.
+-- Aggregates unused bindings as we return from each layer.
+getUsage ::
+  BindingSites ->
+  Syntax ->
+  Usage
+getUsage bindings (Syntax _pos t) = case t of
+  TVar v -> Usage myUsages mempty
+   where
+    myUsages = case M.lookup v bindings of
+      Nothing -> mempty
+      Just (loc :| _) -> S.singleton $ LV loc v
+  SLam v _ s -> checkOccurrences bindings v Lambda [s]
+  SApp s1 s2 -> getUsage bindings s1 <> getUsage bindings s2
+  SLet _ v _ s1 s2 -> getUsage bindings s1 <> checkOccurrences bindings v Let [s2]
+  SPair s1 s2 -> getUsage bindings s1 <> getUsage bindings s2
+  SDef _ _v _ s -> getUsage bindings s
+  SBind maybeVar s1 s2 -> case maybeVar of
+    Just v -> checkOccurrences bindings v Bind [s1, s2]
+    Nothing -> getUsage bindings s1 <> getUsage bindings s2
+  SDelay _ s -> getUsage bindings s
+  _ -> mempty
diff --git a/src/Swarm/Language/Module.hs b/src/Swarm/Language/Module.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Language/Module.hs
@@ -0,0 +1,51 @@
+-- |
+-- Module      :  Swarm.Language.Module
+-- Copyright   :  Brent Yorgey
+-- Maintainer  :  byorgey@gmail.com
+--
+-- SPDX-License-Identifier: BSD-3-Clause
+--
+-- A 'Module' packages together a type-annotated syntax tree along
+-- with a context of top-level definitions.
+module Swarm.Language.Module (
+  -- * Modules
+  Module (..),
+  TModule,
+  UModule,
+  trivMod,
+) where
+
+import Data.Data (Data)
+import Data.Yaml (FromJSON, ToJSON)
+import GHC.Generics (Generic)
+import Swarm.Language.Context (Ctx, empty)
+import Swarm.Language.Syntax (Syntax')
+import Swarm.Language.Types (Polytype, UPolytype, UType)
+
+------------------------------------------------------------
+-- Modules
+------------------------------------------------------------
+
+-- | A module generally represents the result of performing type
+--   inference on a top-level expression, which in particular can
+--   contain definitions ('Swarm.Language.Syntax.TDef').  A module
+--   contains the type-annotated AST of the expression itself, as well
+--   as the context giving the types of any defined variables.
+data Module s t = Module {moduleAST :: Syntax' s, moduleCtx :: Ctx t}
+  deriving (Show, Eq, Functor, Data, Generic, FromJSON, ToJSON)
+
+-- | A 'TModule' is the final result of the type inference process on
+--   an expression: we get a polytype for the expression, and a
+--   context of polytypes for the defined variables.
+type TModule = Module Polytype Polytype
+
+-- | A 'UModule' represents the type of an expression at some
+--   intermediate stage during the type inference process.  We get a
+--   'UType' (/not/ a 'UPolytype') for the expression, which may
+--   contain some free unification or type variables, as well as a
+--   context of 'UPolytype's for any defined variables.
+type UModule = Module UType UPolytype
+
+-- | The trivial module for a given AST, with the empty context.
+trivMod :: Syntax' s -> Module s t
+trivMod t = Module t empty
diff --git a/src/Swarm/Language/Parse.hs b/src/Swarm/Language/Parse.hs
--- a/src/Swarm/Language/Parse.hs
+++ b/src/Swarm/Language/Parse.hs
@@ -33,8 +33,10 @@
   showShortError,
   showErrorPos,
   getLocRange,
+  unTuple,
 ) where
 
+import Control.Lens (view, (^.))
 import Control.Monad.Combinators.Expr
 import Control.Monad.Reader
 import Data.Bifunctor
@@ -87,7 +89,7 @@
        , "text"
        , "dir"
        , "bool"
-       , "robot"
+       , "actor"
        , "cmd"
        , "delay"
        , "let"
@@ -128,13 +130,17 @@
 -- | Parse an identifier, i.e. any non-reserved string containing
 --   alphanumeric characters and underscores and not starting with a
 --   number.
-identifier :: Parser Text
-identifier = (lexeme . try) (p >>= check) <?> "variable name"
+identifier :: Parser Var
+identifier = lvVar <$> locIdentifier
+
+-- | Parse an identifier together with its source location info.
+locIdentifier :: Parser LocVar
+locIdentifier = uncurry LV <$> parseLocG ((lexeme . try) (p >>= check) <?> "variable name")
  where
   p = (:) <$> (letterChar <|> char '_') <*> many (alphaNumChar <|> char '_' <|> char '\'')
   check s
     | toLower t `elem` reservedWords =
-      fail $ "reserved word '" ++ s ++ "' cannot be used as variable name"
+        fail $ "reserved word '" ++ s ++ "' cannot be used as variable name"
     | otherwise = return t
    where
     t = into @Text s
@@ -185,11 +191,11 @@
     -- Otherwise, require all variables to be explicitly quantified
     | S.null free = return $ Forall xs ty
     | otherwise =
-      fail $
-        unlines
-          [ "  Type contains free variable(s): " ++ unwords (map from (S.toList free))
-          , "  Try adding them to the 'forall'."
-          ]
+        fail $
+          unlines
+            [ "  Type contains free variable(s): " ++ unwords (map from (S.toList free))
+            , "  Try adding them to the 'forall'."
+            ]
    where
     free = tyVars ty `S.difference` S.fromList xs
 
@@ -212,7 +218,7 @@
     <|> TyText <$ reserved "text"
     <|> TyDir <$ reserved "dir"
     <|> TyBool <$ reserved "bool"
-    <|> TyRobot <$ reserved "robot"
+    <|> TyActor <$ reserved "actor"
     <|> TyCmd <$> (reserved "cmd" *> parseTypeAtom)
     <|> TyDelay <$> braces parseType
     <|> parens parseType
@@ -229,15 +235,15 @@
   consts = filter isUserFunc allConst
   alternative c = c <$ reserved (syntax $ constInfo c)
 
--- | Add 'Location' to a parser
-parseLocG :: Parser a -> Parser (Location, a)
+-- | Add 'SrcLoc' to a parser
+parseLocG :: Parser a -> Parser (SrcLoc, a)
 parseLocG pa = do
   start <- getOffset
   a <- pa
   end <- getOffset
-  pure (Location start end, a)
+  pure (SrcLoc start end, a)
 
--- | Add 'Location' to a 'Term' parser
+-- | Add 'SrcLoc' to a 'Term' parser
 parseLoc :: Parser Term -> Parser Syntax
 parseLoc pterm = uncurry Syntax <$> parseLocG pterm
 
@@ -255,21 +261,25 @@
           *> ( ( TRequireDevice
                   <$> (textLiteral <?> "device name in double quotes")
                )
-                <|> ( TRequire <$> (fromIntegral <$> integer)
+                <|> ( TRequire
+                        <$> (fromIntegral <$> integer)
                         <*> (textLiteral <?> "entity name in double quotes")
                     )
              )
-        <|> SLam <$> (symbol "\\" *> identifier)
+        <|> SLam
+          <$> (symbol "\\" *> locIdentifier)
           <*> optional (symbol ":" *> parseType)
           <*> (symbol "." *> parseTerm)
-        <|> sLet <$> (reserved "let" *> identifier)
+        <|> sLet
+          <$> (reserved "let" *> locIdentifier)
           <*> optional (symbol ":" *> parsePolytype)
           <*> (symbol "=" *> parseTerm)
           <*> (reserved "in" *> parseTerm)
-        <|> sDef <$> (reserved "def" *> identifier)
+        <|> sDef
+          <$> (reserved "def" *> locIdentifier)
           <*> optional (symbol ":" *> parsePolytype)
           <*> (symbol "=" *> parseTerm <* reserved "end")
-        <|> parens (mkTuple <$> (parseTerm `sepBy` symbol ","))
+        <|> parens (view sTerm . mkTuple <$> (parseTerm `sepBy` symbol ","))
     )
     -- Potential syntax for explicitly requesting memoized delay.
     -- Perhaps we will not need this in the end; see the discussion at
@@ -281,20 +291,27 @@
     <|> parseLoc (SDelay SimpleDelay <$> braces parseTerm)
     <|> parseLoc (ask >>= (guard . (== AllowAntiquoting)) >> parseAntiquotation)
 
-mkTuple :: [Syntax] -> Term
-mkTuple [] = TUnit
-mkTuple [STerm x] = x
-mkTuple (x : xs) = SPair x (STerm (mkTuple xs))
+mkTuple :: [Syntax] -> Syntax
+mkTuple [] = Syntax NoLoc TUnit -- should never happen
+mkTuple [x] = x
+mkTuple (x : xs) = let r = mkTuple xs in loc x r $ SPair x r
+ where
+  loc a b = Syntax $ (a ^. sLoc) <> (b ^. sLoc)
 
+unTuple :: Syntax' ty -> [Syntax' ty]
+unTuple = \case
+  Syntax' _ (SPair s1 s2) _ -> s1 : unTuple s2
+  s -> [s]
+
 -- | Construct an 'SLet', automatically filling in the Boolean field
 --   indicating whether it is recursive.
-sLet :: Var -> Maybe Polytype -> Syntax -> Syntax -> Term
-sLet x ty t1 = SLet (x `S.member` setOf fv (sTerm t1)) x ty t1
+sLet :: LocVar -> Maybe Polytype -> Syntax -> Syntax -> Term
+sLet x ty t1 = SLet (lvVar x `S.member` setOf freeVarsV t1) x ty t1
 
 -- | Construct an 'SDef', automatically filling in the Boolean field
 --   indicating whether it is recursive.
-sDef :: Var -> Maybe Polytype -> Syntax -> Term
-sDef x ty t = SDef (x `S.member` setOf fv (sTerm t)) x ty t
+sDef :: LocVar -> Maybe Polytype -> Syntax -> Term
+sDef x ty t = SDef (lvVar x `S.member` setOf freeVarsV t) x ty t
 
 parseAntiquotation :: Parser Term
 parseAntiquotation =
@@ -307,23 +324,23 @@
 
 mkBindChain :: [Stmt] -> Parser Syntax
 mkBindChain stmts = case last stmts of
-  Binder x _ -> return $ foldr mkBind (STerm (TApp (TConst Return) (TVar x))) stmts
+  Binder x _ -> return $ foldr mkBind (STerm (TApp (TConst Return) (TVar (lvVar x)))) stmts
   BareTerm t -> return $ foldr mkBind t (init stmts)
  where
-  mkBind (BareTerm t1) t2 = loc t1 t2 $ SBind Nothing t1 t2
-  mkBind (Binder x t1) t2 = loc t1 t2 $ SBind (Just x) t1 t2
-  loc a b = Syntax $ sLoc a <> sLoc b
+  mkBind (BareTerm t1) t2 = loc Nothing t1 t2 $ SBind Nothing t1 t2
+  mkBind (Binder x t1) t2 = loc (Just x) t1 t2 $ SBind (Just x) t1 t2
+  loc mx a b = Syntax $ maybe NoLoc lvSrcLoc mx <> (a ^. sLoc) <> (b ^. sLoc)
 
 data Stmt
   = BareTerm Syntax
-  | Binder Text Syntax
+  | Binder LocVar Syntax
   deriving (Show)
 
 parseStmt :: Parser Stmt
 parseStmt =
-  mkStmt <$> optional (try (identifier <* symbol "<-")) <*> parseExpr
+  mkStmt <$> optional (try (locIdentifier <* symbol "<-")) <*> parseExpr
 
-mkStmt :: Maybe Text -> Syntax -> Stmt
+mkStmt :: Maybe LocVar -> Syntax -> Stmt
 mkStmt Nothing = BareTerm
 mkStmt (Just x) = Binder x
 
@@ -339,7 +356,7 @@
     [] -> term
     defs -> foldr1 mkBind defs
  where
-  mkBind t1 t2 = Syntax (sLoc t1 <> sLoc t2) $ SBind Nothing t1 t2
+  mkBind t1 t2 = Syntax ((t1 ^. sLoc) <> (t2 ^. sLoc)) $ SBind Nothing t1 t2
   nestedDefs term' acc = case term' of
     def@(Syntax _ SDef {}) -> def : acc
     (Syntax _ (SApp nestedTerm def@(Syntax _ SDef {}))) -> nestedDefs nestedTerm (def : acc)
@@ -362,7 +379,7 @@
   exprLoc2 :: Parser (Syntax -> Syntax -> Term) -> Parser (Syntax -> Syntax -> Syntax)
   exprLoc2 p = do
     (l, f) <- parseLocG p
-    pure $ \s1 s2 -> Syntax (l <> sLoc s1 <> sLoc s2) $ f s1 s2
+    pure $ \s1 s2 -> Syntax (l <> (s1 ^. sLoc) <> (s2 ^. sLoc)) $ f s1 s2
 
 -- | Precedences and parsers of binary operators.
 --
@@ -405,7 +422,7 @@
   exprLoc1 :: Parser (Syntax -> Term) -> Parser (Syntax -> Syntax)
   exprLoc1 p = do
     (l, f) <- parseLocG p
-    pure $ \s -> Syntax (l <> sLoc s) $ f s
+    pure $ \s -> Syntax (l <> s ^. sLoc) $ f s
 
 operatorString :: Text -> Parser Text
 operatorString n = (lexeme . try) (string n <* notFollowedBy operatorSymbol)
@@ -514,7 +531,7 @@
   line = unPos $ sourceLine $ pstateSourcePos ps
   col = unPos $ sourceColumn $ pstateSourcePos ps
 
--- | A utility for converting a Location into a range
+-- | A utility for converting a SrcLoc into a range
 getLocRange :: Text -> (Int, Int) -> ((Int, Int), (Int, Int))
 getLocRange code (locStart, locEnd) = (start, end)
  where
diff --git a/src/Swarm/Language/Pipeline.hs b/src/Swarm/Language/Pipeline.hs
--- a/src/Swarm/Language/Pipeline.hs
+++ b/src/Swarm/Language/Pipeline.hs
@@ -21,6 +21,7 @@
   showTypeErrorPos,
 ) where
 
+import Control.Lens ((^.))
 import Data.Bifunctor (first)
 import Data.Data (Data)
 import Data.Text (Text)
@@ -28,6 +29,7 @@
 import GHC.Generics (Generic)
 import Swarm.Language.Context
 import Swarm.Language.Elaborate
+import Swarm.Language.Module
 import Swarm.Language.Parse
 import Swarm.Language.Pretty
 import Swarm.Language.Requirement
@@ -40,10 +42,8 @@
 --   pipeline.  Put a 'Term' in, and get one of these out.
 data ProcessedTerm
   = ProcessedTerm
-      Term
-      -- ^ The elaborated term
       TModule
-      -- ^ The type of the term (and of any embedded definitions)
+      -- ^ The elaborated + type-annotated term, plus types of any embedded definitions
       Requirements
       -- ^ Requirements of the term
       ReqCtx
@@ -60,7 +60,7 @@
       Right (Just pt) -> return pt
 
 instance ToJSON ProcessedTerm where
-  toJSON (ProcessedTerm t _ _ _) = String $ prettyText t
+  toJSON (ProcessedTerm t _ _) = String $ prettyText (moduleAST t)
 
 -- | Given a 'Text' value representing a Swarm program,
 --
@@ -87,8 +87,8 @@
 prettyTypeErr :: Text -> TypeErr -> Text
 prettyTypeErr code te = teLoc <> prettyText te
  where
-  teLoc = case getTypeErrLocation te of
-    Just (Location s e) -> (from . show . fst . fst $ getLocRange code (s, e)) <> ": "
+  teLoc = case getTypeErrSrcLoc te of
+    Just (SrcLoc s e) -> (from . show . fst . fst $ getLocRange code (s, e)) <> ": "
     _anyOtherLoc -> ""
 
 showTypeErrorPos :: Text -> TypeErr -> ((Int, Int), (Int, Int), Text)
@@ -96,14 +96,17 @@
  where
   minusOne (x, y) = (x - 1, y - 1)
 
-  (start, end) = case getTypeErrLocation te of
-    Just (Location s e) -> getLocRange code (s, e)
+  (start, end) = case getTypeErrSrcLoc te of
+    Just (SrcLoc s e) -> getLocRange code (s, e)
     _anyOtherLoc -> ((1, 1), (65535, 65535)) -- unknown loc spans the whole document
   msg = prettyText te
 
 -- | Like 'processTerm'', but use a term that has already been parsed.
 processParsedTerm' :: TCtx -> ReqCtx -> Syntax -> Either TypeErr ProcessedTerm
 processParsedTerm' ctx capCtx t = do
-  ty <- inferTop ctx t
-  let (caps, capCtx') = requirements capCtx (sTerm t)
-  return $ ProcessedTerm (elaborate (sTerm t)) ty caps capCtx'
+  m <- inferTop ctx t
+  let (caps, capCtx') = requirements capCtx (t ^. sTerm)
+  return $ ProcessedTerm (elaborateModule m) caps capCtx'
+
+elaborateModule :: TModule -> TModule
+elaborateModule (Module ast ctx) = Module (elaborate ast) ctx
diff --git a/src/Swarm/Language/Pipeline/QQ.hs b/src/Swarm/Language/Pipeline/QQ.hs
--- a/src/Swarm/Language/Pipeline/QQ.hs
+++ b/src/Swarm/Language/Pipeline/QQ.hs
@@ -15,6 +15,7 @@
 import Swarm.Language.Pipeline
 import Swarm.Language.Pretty (prettyText)
 import Swarm.Language.Syntax
+import Swarm.Language.Types (Polytype)
 import Swarm.Util (liftText)
 import Witch (from)
 
@@ -48,7 +49,7 @@
     Left errMsg -> fail $ from $ prettyText errMsg
     Right ptm -> dataToExpQ ((fmap liftText . cast) `extQ` antiTermExp) ptm
 
-antiTermExp :: Term -> Maybe TH.ExpQ
+antiTermExp :: Term' Polytype -> Maybe TH.ExpQ
 antiTermExp (TAntiText v) =
   Just $ TH.appE (TH.conE (TH.mkName "TText")) (TH.varE (TH.mkName (from v)))
 antiTermExp (TAntiInt v) =
diff --git a/src/Swarm/Language/Pretty.hs b/src/Swarm/Language/Pretty.hs
--- a/src/Swarm/Language/Pretty.hs
+++ b/src/Swarm/Language/Pretty.hs
@@ -61,7 +61,7 @@
   prettyPrec _ BDir = "dir"
   prettyPrec _ BText = "text"
   prettyPrec _ BBool = "bool"
-  prettyPrec _ BRobot = "robot"
+  prettyPrec _ BActor = "actor"
 
 instance PrettyPrec IntVar where
   prettyPrec _ = pretty . mkVarName "u"
@@ -111,6 +111,9 @@
 instance PrettyPrec Const where
   prettyPrec p c = pparens (p > fixity (constInfo c)) $ pretty . syntax . constInfo $ c
 
+instance PrettyPrec (Syntax' ty) where
+  prettyPrec p = prettyPrec p . eraseS
+
 instance PrettyPrec Term where
   prettyPrec _ TUnit = "()"
   prettyPrec p (TConst c) = prettyPrec p c
@@ -120,10 +123,10 @@
   prettyPrec _ (TText s) = fromString (show s)
   prettyPrec _ (TAntiText v) = "$str:" <> pretty v
   prettyPrec _ (TBool b) = bool "false" "true" b
-  prettyPrec _ (TRobot r) = "<r" <> pretty r <> ">"
+  prettyPrec _ (TRobot r) = "<a" <> pretty r <> ">"
   prettyPrec _ (TRef r) = "@" <> pretty r
-  prettyPrec p (TRequireDevice d) = pparens (p > 10) $ "require" <+> ppr (TText d)
-  prettyPrec p (TRequire n e) = pparens (p > 10) $ "require" <+> pretty n <+> ppr (TText e)
+  prettyPrec p (TRequireDevice d) = pparens (p > 10) $ "require" <+> ppr @Term (TText d)
+  prettyPrec p (TRequire n e) = pparens (p > 10) $ "require" <+> pretty n <+> ppr @Term (TText e)
   prettyPrec _ (TVar s) = pretty s
   prettyPrec _ (TDelay _ t) = braces $ ppr t
   prettyPrec _ t@TPair {} = prettyTuple t
diff --git a/src/Swarm/Language/Requirement.hs b/src/Swarm/Language/Requirement.hs
--- a/src/Swarm/Language/Requirement.hs
+++ b/src/Swarm/Language/Requirement.hs
@@ -45,15 +45,15 @@
 -- | A /requirement/ is something a robot must have when it is
 --   built. There are three types:
 --   - A robot can require a certain 'Capability', which should be fulfilled
---     by installing an appropriate device.
---   - A robot can require a specific /device/, which should be installed.
+--     by equipping an appropriate device.
+--   - A robot can require a specific /device/, which should be equipped.
 --   - A robot can require some number of a specific entity in its inventory.
 data Requirement
   = -- | Require a specific capability.  This must be fulfilled by
-    --   installing an appropriate device.  Requiring the same
+    --   equipping an appropriate device.  Requiring the same
     --   capability multiple times is the same as requiring it once.
     ReqCap Capability
-  | -- | Require a specific device to be installed.  Note that at this
+  | -- | Require a specific device to be equipped.  Note that at this
     --   point it is only a name, and has not been resolved to an actual
     --   'Entity'.  That's because programs have to be type- and
     --   capability-checked independent of an 'EntityMap'.  The name
@@ -230,7 +230,8 @@
     -- lambda.
     TLet r x _ t1 t2 ->
       (if r then insert (ReqCap CRecursion) else id) $
-        insert (ReqCap CEnv) $ go (Ctx.delete x ctx) t1 <> go (Ctx.delete x ctx) t2
+        insert (ReqCap CEnv) $
+          go (Ctx.delete x ctx) t1 <> go (Ctx.delete x ctx) t2
     -- We also delete the name in a TBind, if any, while recursing on
     -- the RHS.
     TBind mx t1 t2 -> go ctx t1 <> go (maybe id Ctx.delete mx ctx) t2
diff --git a/src/Swarm/Language/Syntax.hs b/src/Swarm/Language/Syntax.hs
--- a/src/Swarm/Language/Syntax.hs
+++ b/src/Swarm/Language/Syntax.hs
@@ -2,7 +2,10 @@
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE PatternSynonyms #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TemplateHaskell #-}
 {-# LANGUAGE UndecidableInstances #-}
+{-# LANGUAGE ViewPatterns #-}
 
 -- |
 -- Module      :  Swarm.Language.Syntax
@@ -15,6 +18,8 @@
 module Swarm.Language.Syntax (
   -- * Directions
   Direction (..),
+  AbsoluteDir (..),
+  RelativeDir (..),
   DirInfo (..),
   applyTurn,
   toDirection,
@@ -45,8 +50,14 @@
   isLong,
 
   -- * Syntax
-  Syntax (..),
-  Location (..),
+  Syntax' (..),
+  sLoc,
+  sTerm,
+  sType,
+  Syntax,
+  pattern Syntax,
+  LocVar (..),
+  SrcLoc (..),
   noLoc,
   pattern STerm,
   pattern TPair,
@@ -61,24 +72,35 @@
   -- * Terms
   Var,
   DelayType (..),
-  Term (..),
+  Term' (..),
+  Term,
   mkOp,
   mkOp',
+  unfoldApps,
 
+  -- * Erasure
+  erase,
+  eraseS,
+
   -- * Term traversal
-  fvT,
-  fv,
-  mapFree1,
+  freeVarsS,
+  freeVarsT,
+  freeVarsV,
+  mapFreeS,
+  locVarToSyntax',
 ) where
 
-import Control.Lens (Plated (..), Traversal', (%~))
+import Control.Arrow (Arrow ((&&&)))
+import Control.Lens (Plated (..), Traversal', makeLenses, (%~), (^.))
 import Data.Aeson.Types
+import Data.Char qualified as C (toLower)
 import Data.Data (Data)
 import Data.Data.Lens (uniplate)
 import Data.Hashable (Hashable)
-import Data.Int (Int64)
+import Data.List qualified as L (tail)
+import Data.List.NonEmpty (NonEmpty)
+import Data.List.NonEmpty qualified as NonEmpty
 import Data.Map qualified as M
-import Data.Maybe (fromMaybe, isJust, mapMaybe)
 import Data.Set qualified as S
 import Data.String (IsString (fromString))
 import Data.Text hiding (filter, map)
@@ -86,103 +108,135 @@
 import GHC.Generics (Generic)
 import Linear
 import Swarm.Language.Types
+import Swarm.Util qualified as Util
+import Swarm.Util.Location (Heading)
 import Witch.From (from)
 
 ------------------------------------------------------------
--- Constants
+-- Directions
 ------------------------------------------------------------
 
--- | The type of directions. Used /e.g./ to indicate which way a robot
---   will turn.
-data Direction = DLeft | DRight | DBack | DForward | DNorth | DSouth | DEast | DWest | DDown
+-- | An absolute direction is one which is defined with respect to an
+--   external frame of reference; robots need a compass in order to
+--   use them.
+data AbsoluteDir = DNorth | DSouth | DEast | DWest
   deriving (Eq, Ord, Show, Read, Generic, Data, Hashable, ToJSON, FromJSON, Enum, Bounded)
 
-instance ToJSONKey Direction where
-  toJSONKey = genericToJSONKey defaultJSONKeyOptions
+cardinalDirectionKeyOptions :: JSONKeyOptions
+cardinalDirectionKeyOptions =
+  defaultJSONKeyOptions
+    { keyModifier = map C.toLower . L.tail
+    }
 
-instance FromJSONKey Direction where
-  fromJSONKey = genericFromJSONKey defaultJSONKeyOptions
+instance ToJSONKey AbsoluteDir where
+  toJSONKey = genericToJSONKey cardinalDirectionKeyOptions
 
+instance FromJSONKey AbsoluteDir where
+  fromJSONKey = genericFromJSONKey cardinalDirectionKeyOptions
+
+-- | A relative direction is one which is defined with respect to the
+--   robot's frame of reference; no special capability is needed to
+--   use them.
+data RelativeDir = DLeft | DRight | DBack | DForward | DDown
+  deriving (Eq, Ord, Show, Read, Generic, Data, Hashable, ToJSON, FromJSON, Enum, Bounded)
+
+-- | The type of directions. Used /e.g./ to indicate which way a robot
+--   will turn.
+data Direction = DAbsolute AbsoluteDir | DRelative RelativeDir
+  deriving (Eq, Ord, Show, Read, Generic, Data, Hashable, ToJSON, FromJSON)
+
 data DirInfo = DirInfo
   { dirSyntax :: Text
-  , -- absolute direction if it exists
-    dirAbs :: Maybe (V2 Int64)
-  , -- the turning for the direction
-    dirApplyTurn :: V2 Int64 -> V2 Int64
+  , dirApplyTurn :: Heading -> Heading
+  -- ^ the turning for the direction
   }
 
 allDirs :: [Direction]
-allDirs = [minBound .. maxBound]
+allDirs = map DAbsolute Util.listEnums <> map DRelative Util.listEnums
 
+toHeading :: AbsoluteDir -> Heading
+toHeading = \case
+  DNorth -> north
+  DSouth -> south
+  DEast -> east
+  DWest -> west
+
 -- | Information about all directions
 dirInfo :: Direction -> DirInfo
 dirInfo d = case d of
-  DLeft -> relative (\(V2 x y) -> V2 (-y) x)
-  DRight -> relative (\(V2 x y) -> V2 y (-x))
-  DBack -> relative (\(V2 x y) -> V2 (-x) (-y))
-  DDown -> relative (const down)
-  DForward -> relative id
-  DNorth -> cardinal north
-  DSouth -> cardinal south
-  DEast -> cardinal east
-  DWest -> cardinal west
+  DRelative e -> case e of
+    DLeft -> relative perp
+    DRight -> relative (fmap negate . perp)
+    DBack -> relative (fmap negate)
+    DDown -> relative (const down)
+    DForward -> relative id
+  DAbsolute e -> cardinal $ toHeading e
  where
   -- name is generate from Direction data constuctor
   -- e.g. DLeft becomes "left"
-  directionSyntax = toLower . T.tail . from . show $ d
-  cardinal v2 = DirInfo directionSyntax (Just v2) (const v2)
-  relative = DirInfo directionSyntax Nothing
+  directionSyntax = toLower . T.tail . from $ case d of
+    DAbsolute x -> show x
+    DRelative x -> show x
 
+  cardinal = DirInfo directionSyntax . const
+  relative = DirInfo directionSyntax
+
 -- | Check if the direction is absolute (e.g. 'north' or 'south').
 isCardinal :: Direction -> Bool
-isCardinal = isJust . dirAbs . dirInfo
+isCardinal = \case
+  DAbsolute _ -> True
+  _ -> False
 
 -- | The cardinal direction north = @V2 0 1@.
-north :: V2 Int64
+north :: Heading
 north = V2 0 1
 
 -- | The cardinal direction south = @V2 0 (-1)@.
-south :: V2 Int64
+south :: Heading
 south = V2 0 (-1)
 
 -- | The cardinal direction east = @V2 1 0@.
-east :: V2 Int64
+east :: Heading
 east = V2 1 0
 
 -- | The cardinal direction west = @V2 (-1) 0@.
-west :: V2 Int64
+west :: Heading
 west = V2 (-1) 0
 
 -- | The direction for viewing the current cell = @V2 0 0@.
-down :: V2 Int64
-down = V2 0 0
+down :: Heading
+down = zero
 
 -- | The 'applyTurn' function gives the meaning of each 'Direction' by
---   turning relative to the given vector or by turning to an absolute
---   direction vector.
-applyTurn :: Direction -> V2 Int64 -> V2 Int64
+--   turning relative to the given heading or by turning to an absolute
+--   heading
+applyTurn :: Direction -> Heading -> Heading
 applyTurn = dirApplyTurn . dirInfo
 
--- | Mapping from heading to their corresponding cardinal directions
---   only directions with a 'dirAbs` value are mapped
-cardinalDirs :: M.Map (V2 Int64) Direction
+-- | Mapping from heading to their corresponding cardinal directions.
+--   Only absolute directions are mapped.
+cardinalDirs :: M.Map Heading Direction
 cardinalDirs =
-  M.fromList
-    . mapMaybe (\d -> (,d) <$> (dirAbs . dirInfo $ d))
-    $ allDirs
+  M.fromList $ map (toHeading &&& DAbsolute) Util.listEnums
 
--- | Possibly convert a vector into a 'Direction'---that is, if the
+-- | Possibly convert a heading into a 'Direction'---that is, if the
 --   vector happens to be a unit vector in one of the cardinal
 --   directions.
-toDirection :: V2 Int64 -> Maybe Direction
+toDirection :: Heading -> Maybe Direction
 toDirection v = M.lookup v cardinalDirs
 
--- | Convert a 'Direction' into a corresponding vector.  Note that
+-- | Convert a 'Direction' into a corresponding heading.  Note that
 --   this only does something reasonable for 'DNorth', 'DSouth', 'DEast',
 --   and 'DWest'---other 'Direction's return the zero vector.
-fromDirection :: Direction -> V2 Int64
-fromDirection = fromMaybe (V2 0 0) . dirAbs . dirInfo
+fromDirection :: Direction -> Heading
+fromDirection = \case
+  DAbsolute x -> toHeading x
+  _ -> zero
 
+------------------------------------------------------------
+-- Constants
+------------------------------------------------------------
+
 -- | Constants, representing various built-in functions and commands.
 --
 --   IF YOU ADD A NEW CONSTANT, be sure to also update:
@@ -193,9 +247,10 @@
 --   5. the emacs mode syntax highlighter (@contribs/swarm-mode.el@)
 --
 --   GHC will warn you about incomplete pattern matches for the first
---   four, so it's not really possible to forget.  Note you do not
---   need to update the parser or pretty-printer, since they are
---   auto-generated from 'constInfo'.
+--   four, and CI will warn you about the last, so in theory it's not
+--   really possible to forget.  Note you do not need to update the
+--   parser or pretty-printer, since they are auto-generated from
+--   'constInfo'.
 data Const
   = -- Trivial actions
 
@@ -220,14 +275,16 @@
     Place
   | -- | Give an item to another robot at the current location.
     Give
-  | -- | Install a device on a robot.
-    Install
+  | -- | Equip a device on oneself.
+    Equip
+  | -- | Unequip an equipped device, returning to inventory.
+    Unequip
   | -- | Make an item.
     Make
   | -- | Sense whether we have a certain item.
     Has
-  | -- | Sense whether we have a certain device installed.
-    Installed
+  | -- | Sense whether we have a certain device equipped.
+    Equipped
   | -- | Sense how many of a certain item we have.
     Count
   | -- | Drill through an entity.
@@ -258,20 +315,28 @@
     Time
   | -- | Get the current x, y coordinates
     Whereami
+  | -- | Get the current heading.
+    Heading
   | -- | See if we can move forward or not.
     Blocked
   | -- | Scan a nearby cell
     Scan
   | -- | Upload knowledge to another robot
     Upload
-  | -- | See if a specific entity is here. (This may be removed.)
+  | -- | See if a specific entity is here.
     Ishere
+  | -- | Check whether the current cell is empty
+    Isempty
   | -- | Get a reference to oneself
     Self
   | -- | Get the robot's parent
     Parent
   | -- | Get a reference to the base
     Base
+  | -- | Meet a nearby robot
+    Meet
+  | -- | Meet all nearby robots
+    MeetAll
   | -- | Get the robot's display name
     Whoami
   | -- | Set the robot's display name
@@ -352,6 +417,10 @@
     Chars
   | -- | Split string into two parts.
     Split
+  | -- | Get the character at an index.
+    CharAt
+  | -- | Create a singleton text value with the given character code.
+    ToChar
   | -- Function composition with nice operators
 
     -- | Application operator - helps to avoid parentheses:
@@ -371,16 +440,16 @@
     Teleport
   | -- | Run a command as if you were another robot.
     As
-  | -- | Find a robot by name.
+  | -- | Find an actor by name.
     RobotNamed
-  | -- | Find a robot by number.
+  | -- | Find an actor by number.
     RobotNumbered
   | -- | Check if an entity is known.
     Knows
   deriving (Eq, Ord, Enum, Bounded, Data, Show, Generic, FromJSON, ToJSON)
 
 allConst :: [Const]
-allConst = [minBound .. maxBound]
+allConst = Util.listEnums
 
 data ConstInfo = ConstInfo
   { syntax :: Text
@@ -451,7 +520,7 @@
 
 -- | The arity of a constant, /i.e./ how many arguments it expects.
 --   The runtime system will collect arguments to a constant (see
---   'Swarm.Game.Value.VCApp') until it has enough, then dispatch
+--   'Swarm.Language.Value.VCApp') until it has enough, then dispatch
 --   the constant's behavior.
 arity :: Const -> Int
 arity c = case constMeta $ constInfo c of
@@ -524,7 +593,7 @@
       , "Usually it is automatically inserted where needed, so you do not have to worry about it."
       ]
   Selfdestruct ->
-    command 0 short . doc "Self-destruct the robot." $
+    command 0 short . doc "Self-destruct a robot." $
       [ "Useful to not clutter the world."
       , "This destroys the robot's inventory, so consider `salvage` as an alternative."
       ]
@@ -539,11 +608,12 @@
   Place ->
     command 1 short . doc "Place an item at the current location." $
       ["The current location has to be empty for this to work."]
-  Give -> command 2 short "Give an item to another robot nearby."
-  Install -> command 2 short "Install a device from inventory on a robot."
+  Give -> command 2 short "Give an item to another actor nearby."
+  Equip -> command 1 short "Equip a device on oneself."
+  Unequip -> command 1 short "Unequip an equipped device, returning to inventory."
   Make -> command 1 long "Make an item using a recipe."
   Has -> command 1 Intangible "Sense whether the robot has a given item in its inventory."
-  Installed -> command 1 Intangible "Sense whether the robot has a specific device installed."
+  Equipped -> command 1 Intangible "Sense whether the robot has a specific device equipped."
   Count -> command 1 Intangible "Get the count of a given item in a robot's inventory."
   Reprogram ->
     command 2 long . doc "Reprogram another robot with a new command." $
@@ -557,29 +627,30 @@
   Build ->
     command 1 long . doc "Construct a new robot." $
       [ "You can specify a command for the robot to execute."
-      , "If the command requires devices they will be installed from your inventory."
+      , "If the command requires devices they will be taken from your inventory and "
+          <> "equipped on the new robot."
       ]
   Salvage ->
     command 0 long . doc "Deconstruct an old robot." $
-      ["Salvaging a robot will give you its inventory, installed devices and log."]
+      ["Salvaging a robot will give you its inventory, equipped devices and log."]
   Say ->
     command 1 short . doc "Emit a message." $
-      [ "The message will be in the robots log (if it has one) and the global log."
+      [ "The message will be in the robot's log (if it has one) and the global log."
       , "You can view the message that would be picked by `listen` from the global log "
           <> "in the messages panel, along with your own messages and logs."
       , "This means that to see messages from other robots you have to be able to listen for them, "
-          <> "so once you have a listening device installed messages will be added to your log."
+          <> "so once you have a listening device equipped messages will be added to your log."
       , "In creative mode, there is of course no such limitation."
       ]
   Listen ->
-    command 1 long . doc "Listen for a message from other robots." $
-      [ "It will take the first message said by the closest robot."
+    command 1 long . doc "Listen for a message from other actors." $
+      [ "It will take the first message said by the closest actor."
       , "You do not need to actively listen for the message to be logged though, "
-          <> "that is done automatically once you have a listening device installed."
+          <> "that is done automatically once you have a listening device equipped."
       , "Note that you can see the messages either in your logger device or the message panel."
       ]
-  Log -> command 1 Intangible "Log the string in the robot's logger."
-  View -> command 1 short "View the given robot."
+  Log -> command 1 short "Log the string in the robot's logger."
+  View -> command 1 short "View the given actor."
   Appear ->
     command 1 short . doc "Set how the robot is displayed." $
       [ "You can either specify one character or five (for each direction)."
@@ -590,17 +661,25 @@
       ["Only available in creative mode."]
   Time -> command 0 Intangible "Get the current time."
   Whereami -> command 0 Intangible "Get the current x and y coordinates."
+  Heading -> command 0 Intangible "Get the current heading."
   Blocked -> command 0 Intangible "See if the robot can move forward."
   Scan ->
-    command 0 Intangible . doc "Scan a nearby location for entities." $
-      [ "Adds the entity (not robot) to your inventory with count 0 if there is any."
+    command 1 Intangible . doc "Scan a nearby location for entities." $
+      [ "Adds the entity (not actor) to your inventory with count 0 if there is any."
       , "If you can use sum types, you can also inspect the result directly."
       ]
   Upload -> command 1 short "Upload a robot's known entities and log to another robot."
   Ishere -> command 1 Intangible "See if a specific entity is in the current location."
+  Isempty ->
+    command 0 Intangible . doc "Check if the current location is empty." $
+      [ "Detects whether or not the current location contains an entity."
+      , "Does not detect robots or other actors."
+      ]
   Self -> function 0 "Get a reference to the current robot."
   Parent -> function 0 "Get a reference to the robot's parent."
   Base -> function 0 "Get a reference to the base."
+  Meet -> command 0 Intangible "Get a reference to a nearby actor, if there is one."
+  MeetAll -> command 0 long "Run a command for each nearby actor."
   Whoami -> command 0 Intangible "Get the robot's display name."
   Setname -> command 1 short "Set the robot's display name."
   Random ->
@@ -644,6 +723,15 @@
       , "`(s1,s2) == split (chars s1) (s1 ++ s2)`"
       , "So split can be used to undo concatenation if you know the length of the original string."
       ]
+  CharAt ->
+    function 2 . doc "Get the character at a given index." $
+      [ "Gets the character (as an `int` representing a Unicode codepoint) at a specific index in a `text` value.  Valid indices are 0 through `chars t - 1`."
+      , "Throws an exception if given an out-of-bounds index."
+      ]
+  ToChar ->
+    function 1 . doc "Create a singleton `text` value from the given character code." $
+      [ "That is, `chars (toChar c) == 1` and `charAt 0 (toChar c) == c`."
+      ]
   AppF ->
     binaryOp "$" 0 R . doc "Apply the function on the left to the value on the right." $
       [ "This operator is useful to avoid nesting parentheses."
@@ -661,8 +749,8 @@
       ]
   Teleport -> command 2 short "Teleport a robot to the given location."
   As -> command 2 Intangible "Hypothetically run a command as if you were another robot."
-  RobotNamed -> command 1 Intangible "Find a robot by name."
-  RobotNumbered -> command 1 Intangible "Find a robot by number."
+  RobotNamed -> command 1 Intangible "Find an actor by name."
+  RobotNumbered -> command 1 Intangible "Find an actor by number."
   Knows -> command 1 Intangible "Check if the robot knows about an entity."
  where
   doc b ls = ConstDoc b (T.unlines ls)
@@ -699,86 +787,12 @@
       , tangibility = Intangible
       }
 
--- | Make infix operation, discarding any syntax related location
-mkOp' :: Const -> Term -> Term -> Term
-mkOp' c t1 = TApp (TApp (TConst c) t1)
-
--- | Make infix operation (e.g. @2 + 3@) a curried function
---   application (@((+) 2) 3@).
-mkOp :: Const -> Syntax -> Syntax -> Syntax
-mkOp c s1@(Syntax l1 _) s2@(Syntax l2 _) = Syntax newLoc newTerm
- where
-  -- The new syntax span both terms
-  newLoc = l1 <> l2
-  -- We don't assign a source location for the operator since it is
-  -- usually provided as-is and it is not likely to be useful.
-  sop = noLoc (TConst c)
-  newTerm = SApp (Syntax l1 $ SApp sop s1) s2
-
--- | The surface syntax for the language
-data Syntax = Syntax {sLoc :: Location, sTerm :: Term}
-  deriving (Eq, Show, Data, Generic, FromJSON, ToJSON)
-
-data Location = NoLoc | Location Int Int
-  deriving (Eq, Show, Data, Generic, FromJSON, ToJSON)
-
-instance Semigroup Location where
-  NoLoc <> l = l
-  l <> NoLoc = l
-  Location s1 e1 <> Location s2 e2 = Location (min s1 s2) (max e1 e2)
-
-instance Monoid Location where
-  mempty = NoLoc
-
-noLoc :: Term -> Syntax
-noLoc = Syntax mempty
-
--- | Match a term without its a syntax
-pattern STerm :: Term -> Syntax
-pattern STerm t <-
-  Syntax _ t
-  where
-    STerm t = Syntax mempty t
-
--- | Match a TPair without syntax
-pattern TPair :: Term -> Term -> Term
-pattern TPair t1 t2 = SPair (STerm t1) (STerm t2)
-
--- | Match a TLam without syntax
-pattern TLam :: Var -> Maybe Type -> Term -> Term
-pattern TLam v ty t = SLam v ty (STerm t)
-
--- | Match a TApp without syntax
-pattern TApp :: Term -> Term -> Term
-pattern TApp t1 t2 = SApp (STerm t1) (STerm t2)
-
-infixl 0 :$:
-
--- | Convenient infix pattern synonym for application.
-pattern (:$:) :: Term -> Syntax -> Term
-pattern (:$:) t1 s2 = SApp (STerm t1) s2
-
--- | Match a TLet without syntax
-pattern TLet :: Bool -> Var -> Maybe Polytype -> Term -> Term -> Term
-pattern TLet r v pt t1 t2 = SLet r v pt (STerm t1) (STerm t2)
-
--- | Match a TDef without syntax
-pattern TDef :: Bool -> Var -> Maybe Polytype -> Term -> Term
-pattern TDef r v pt t = SDef r v pt (STerm t)
-
--- | Match a TBind without syntax
-pattern TBind :: Maybe Var -> Term -> Term -> Term
-pattern TBind v t1 t2 = SBind v (STerm t1) (STerm t2)
-
--- | Match a TDelay without syntax
-pattern TDelay :: DelayType -> Term -> Term
-pattern TDelay m t = SDelay m (STerm t)
-
--- | COMPLETE pragma tells GHC using this set of pattern is complete for Term
-{-# COMPLETE TUnit, TConst, TDir, TInt, TAntiInt, TText, TAntiText, TBool, TRequireDevice, TRequire, TVar, TPair, TLam, TApp, TLet, TDef, TBind, TDelay #-}
+  lowShow :: Show a => a -> Text
+  lowShow a = toLower (from (show a))
 
 ------------------------------------------------------------
--- Terms
+-- Basic terms
+------------------------------------------------------------
 
 -- | Different runtime behaviors for delayed expressions.
 data DelayType
@@ -797,8 +811,17 @@
     MemoizedDelay (Maybe Var)
   deriving (Eq, Show, Data, Generic, FromJSON, ToJSON)
 
+-- | A variable with associated source location, used for variable
+--   binding sites. (Variable occurrences are a bare TVar which gets
+--   wrapped in a Syntax node, so we don't need LocVar for those.)
+data LocVar = LV {lvSrcLoc :: SrcLoc, lvVar :: Var}
+  deriving (Eq, Ord, Show, Data, Generic, FromJSON, ToJSON)
+
+locVarToSyntax' :: LocVar -> ty -> Syntax' ty
+locVarToSyntax' (LV s v) = Syntax' s (TVar v)
+
 -- | Terms of the Swarm language.
-data Term
+data Term' ty
   = -- | The unit value.
     TUnit
   | -- | A constant.
@@ -815,7 +838,7 @@
     TAntiText Text
   | -- | A Boolean literal.
     TBool Bool
-  | -- | A robot value.  These never show up in surface syntax, but are
+  | -- | A robot reference.  These never show up in surface syntax, but are
     --   here so we can factor pretty-printing for Values through
     --   pretty-printing for Terms.
     TRobot Int
@@ -829,22 +852,22 @@
   | -- | A variable.
     TVar Var
   | -- | A pair.
-    SPair Syntax Syntax
+    SPair (Syntax' ty) (Syntax' ty)
   | -- | A lambda expression, with or without a type annotation on the
     --   binder.
-    SLam Var (Maybe Type) Syntax
+    SLam LocVar (Maybe Type) (Syntax' ty)
   | -- | Function application.
-    SApp Syntax Syntax
+    SApp (Syntax' ty) (Syntax' ty)
   | -- | A (recursive) let expression, with or without a type
     --   annotation on the variable. The @Bool@ indicates whether
     --   it is known to be recursive.
-    SLet Bool Var (Maybe Polytype) Syntax Syntax
+    SLet Bool LocVar (Maybe Polytype) (Syntax' ty) (Syntax' ty)
   | -- | A (recursive) definition command, which binds a variable to a
     --   value in subsequent commands. The @Bool@ indicates whether the
     --   definition is known to be recursive.
-    SDef Bool Var (Maybe Polytype) Syntax
+    SDef Bool LocVar (Maybe Polytype) (Syntax' ty)
   | -- | A monadic bind for commands, of the form @c1 ; c2@ or @x <- c1; c2@.
-    SBind (Maybe Var) Syntax Syntax
+    SBind (Maybe LocVar) (Syntax' ty) (Syntax' ty)
   | -- | Delay evaluation of a term, written @{...}@.  Swarm is an
     --   eager language, but in some cases (e.g. for @if@ statements
     --   and recursive bindings) we need to delay evaluation.  The
@@ -852,57 +875,239 @@
     --   Note that 'Force' is just a constant, whereas 'SDelay' has to
     --   be a special syntactic form so its argument can get special
     --   treatment during evaluation.
-    SDelay DelayType Syntax
-  deriving (Eq, Show, Data, Generic, FromJSON, ToJSON)
+    SDelay DelayType (Syntax' ty)
+  deriving (Eq, Show, Functor, Foldable, Traversable, Data, Generic, FromJSON, ToJSON)
 
-instance Plated Term where
+-- The Traversable instance for Term (and for Syntax') is used during
+-- typechecking: during intermediate type inference, many of the type
+-- annotations placed on AST nodes will have unification variables in
+-- them. Once we have finished solving everything we need to do a
+-- final traversal over all the types in the AST to substitute away
+-- all the unification variables (and generalize, i.e. stick 'forall'
+-- on, as appropriate).  See the call to 'mapM' in
+-- Swarm.Language.Typecheck.runInfer.
+
+type Term = Term' ()
+
+instance Data ty => Plated (Term' ty) where
   plate = uniplate
 
+------------------------------------------------------------
+-- Syntax: annotation on top of Terms with SrcLoc and type
+------------------------------------------------------------
+
+-- | The surface syntax for the language, with location and type annotations.
+data Syntax' ty = Syntax'
+  { _sLoc :: SrcLoc
+  , _sTerm :: Term' ty
+  , _sType :: ty
+  }
+  deriving (Eq, Show, Functor, Foldable, Traversable, Data, Generic, FromJSON, ToJSON)
+
+instance Data ty => Plated (Syntax' ty) where
+  plate = uniplate
+
+data SrcLoc
+  = NoLoc
+  | -- | Half-open interval from start (inclusive) to end (exclusive)
+    SrcLoc Int Int
+  deriving (Eq, Ord, Show, Data, Generic, FromJSON, ToJSON)
+
+instance Semigroup SrcLoc where
+  NoLoc <> l = l
+  l <> NoLoc = l
+  SrcLoc s1 e1 <> SrcLoc s2 e2 = SrcLoc (min s1 s2) (max e1 e2)
+
+instance Monoid SrcLoc where
+  mempty = NoLoc
+
+------------------------------------------------------------
+-- Pattern synonyms for untyped terms
+------------------------------------------------------------
+
+type Syntax = Syntax' ()
+
+pattern Syntax :: SrcLoc -> Term -> Syntax
+pattern Syntax l t = Syntax' l t ()
+
+{-# COMPLETE Syntax #-}
+
+makeLenses ''Syntax'
+
+noLoc :: Term -> Syntax
+noLoc = Syntax mempty
+
+-- | Match an untyped term without its 'SrcLoc'.
+pattern STerm :: Term -> Syntax
+pattern STerm t <-
+  Syntax _ t
+  where
+    STerm t = Syntax mempty t
+
+-- | Match a TPair without syntax
+pattern TPair :: Term -> Term -> Term
+pattern TPair t1 t2 = SPair (STerm t1) (STerm t2)
+
+-- | Match a TLam without syntax
+pattern TLam :: Var -> Maybe Type -> Term -> Term
+pattern TLam v ty t <- SLam (lvVar -> v) ty (STerm t)
+  where
+    TLam v ty t = SLam (LV NoLoc v) ty (STerm t)
+
+-- | Match a TApp without syntax
+pattern TApp :: Term -> Term -> Term
+pattern TApp t1 t2 = SApp (STerm t1) (STerm t2)
+
+infixl 0 :$:
+
+-- | Convenient infix pattern synonym for application.
+pattern (:$:) :: Term -> Syntax -> Term
+pattern (:$:) t1 s2 = SApp (STerm t1) s2
+
+-- | Match a TLet without syntax
+pattern TLet :: Bool -> Var -> Maybe Polytype -> Term -> Term -> Term
+pattern TLet r v pt t1 t2 <- SLet r (lvVar -> v) pt (STerm t1) (STerm t2)
+  where
+    TLet r v pt t1 t2 = SLet r (LV NoLoc v) pt (STerm t1) (STerm t2)
+
+-- | Match a TDef without syntax
+pattern TDef :: Bool -> Var -> Maybe Polytype -> Term -> Term
+pattern TDef r v pt t <- SDef r (lvVar -> v) pt (STerm t)
+  where
+    TDef r v pt t = SDef r (LV NoLoc v) pt (STerm t)
+
+-- | Match a TBind without syntax
+pattern TBind :: Maybe Var -> Term -> Term -> Term
+pattern TBind mv t1 t2 <- SBind (fmap lvVar -> mv) (STerm t1) (STerm t2)
+  where
+    TBind mv t1 t2 = SBind (LV NoLoc <$> mv) (STerm t1) (STerm t2)
+
+-- | Match a TDelay without syntax
+pattern TDelay :: DelayType -> Term -> Term
+pattern TDelay m t = SDelay m (STerm t)
+
+-- | COMPLETE pragma tells GHC using this set of pattern is complete for Term
+{-# COMPLETE TUnit, TConst, TDir, TInt, TAntiInt, TText, TAntiText, TBool, TRequireDevice, TRequire, TVar, TPair, TLam, TApp, TLet, TDef, TBind, TDelay #-}
+
+-- | Make infix operation (e.g. @2 + 3@) a curried function
+--   application (@((+) 2) 3@).
+mkOp :: Const -> Syntax -> Syntax -> Syntax
+mkOp c s1@(Syntax l1 _) s2@(Syntax l2 _) = Syntax newLoc newTerm
+ where
+  -- The new syntax span both terms
+  newLoc = l1 <> l2
+  -- We don't assign a source location for the operator since it is
+  -- usually provided as-is and it is not likely to be useful.
+  sop = noLoc (TConst c)
+  newTerm = SApp (Syntax l1 $ SApp sop s1) s2
+
+-- | Make infix operation, discarding any syntax related location
+mkOp' :: Const -> Term -> Term -> Term
+mkOp' c t1 = TApp (TApp (TConst c) t1)
+
+-- $setup
+-- >>> import Control.Lens ((^.))
+
+-- | Turn function application chain into a list.
+--
+-- >>> syntaxWrap f = fmap (^. sTerm) . f . Syntax NoLoc
+-- >>> syntaxWrap unfoldApps (mkOp' Mul (TInt 1) (TInt 2)) -- 1 * 2
+-- TConst Mul :| [TInt 1,TInt 2]
+unfoldApps :: Syntax' ty -> NonEmpty (Syntax' ty)
+unfoldApps trm = NonEmpty.reverse . flip NonEmpty.unfoldr trm $ \case
+  Syntax' _ (SApp s1 s2) _ -> (s2, Just s1)
+  s -> (s, Nothing)
+
+--------------------------------------------------
+-- Erasure
+
+-- | Erase a 'Syntax' tree annotated with @SrcLoc@ and type
+--   information to a bare unannotated 'Term'.
+eraseS :: Syntax' ty -> Term
+eraseS (Syntax' _ t _) = erase t
+
+-- | Erase a type-annotated term to a bare term.
+erase :: Term' ty -> Term
+erase TUnit = TUnit
+erase (TConst c) = TConst c
+erase (TDir d) = TDir d
+erase (TInt n) = TInt n
+erase (TAntiInt v) = TAntiInt v
+erase (TText t) = TText t
+erase (TAntiText v) = TAntiText v
+erase (TBool b) = TBool b
+erase (TRobot r) = TRobot r
+erase (TRef r) = TRef r
+erase (TRequireDevice d) = TRequireDevice d
+erase (TRequire n e) = TRequire n e
+erase (TVar s) = TVar s
+erase (SDelay x s) = TDelay x (eraseS s)
+erase (SPair s1 s2) = TPair (eraseS s1) (eraseS s2)
+erase (SLam x mty body) = TLam (lvVar x) mty (eraseS body)
+erase (SApp s1 s2) = TApp (eraseS s1) (eraseS s2)
+erase (SLet r x mty s1 s2) = TLet r (lvVar x) mty (eraseS s1) (eraseS s2)
+erase (SDef r x mty s) = TDef r (lvVar x) mty (eraseS s)
+erase (SBind mx s1 s2) = TBind (lvVar <$> mx) (eraseS s1) (eraseS s2)
+
+------------------------------------------------------------
+-- Free variable traversals
+------------------------------------------------------------
+
 -- | Traversal over those subterms of a term which represent free
---   variables.
-fvT :: Traversal' Term Term
-fvT f = go S.empty
+--   variables.  The S suffix indicates that it is a `Traversal' over
+--   the `Syntax` nodes (which contain type and source location info)
+--   containing free variables inside a larger `Syntax` value.  Note
+--   that if you want to get the list of all `Syntax` nodes
+--   representing free variables, you can do so via @'toListOf'
+--   'freeVarsS'@.
+freeVarsS :: forall ty. Traversal' (Syntax' ty) (Syntax' ty)
+freeVarsS f = go S.empty
  where
-  go bound t = case t of
-    TUnit -> pure t
-    TConst {} -> pure t
-    TDir {} -> pure t
-    TInt {} -> pure t
-    TAntiInt {} -> pure t
-    TText {} -> pure t
-    TAntiText {} -> pure t
-    TBool {} -> pure t
-    TRobot {} -> pure t
-    TRef {} -> pure t
-    TRequireDevice {} -> pure t
-    TRequire {} -> pure t
+  -- go :: Applicative f => Set Var -> Syntax' ty -> f (Syntax' ty)
+  go bound s@(Syntax' l t ty) = case t of
+    TUnit -> pure s
+    TConst {} -> pure s
+    TDir {} -> pure s
+    TInt {} -> pure s
+    TAntiInt {} -> pure s
+    TText {} -> pure s
+    TAntiText {} -> pure s
+    TBool {} -> pure s
+    TRobot {} -> pure s
+    TRef {} -> pure s
+    TRequireDevice {} -> pure s
+    TRequire {} -> pure s
     TVar x
-      | x `S.member` bound -> pure t
-      | otherwise -> f (TVar x)
-    SLam x ty (Syntax l1 t1) -> SLam x ty <$> (Syntax l1 <$> go (S.insert x bound) t1)
-    SApp (Syntax l1 t1) (Syntax l2 t2) ->
-      SApp <$> (Syntax l1 <$> go bound t1) <*> (Syntax l2 <$> go bound t2)
-    SLet r x ty (Syntax l1 t1) (Syntax l2 t2) ->
-      let bound' = S.insert x bound
-       in SLet r x ty <$> (Syntax l1 <$> go bound' t1) <*> (Syntax l2 <$> go bound' t2)
-    SPair (Syntax l1 t1) (Syntax l2 t2) ->
-      SPair <$> (Syntax l1 <$> go bound t1) <*> (Syntax l2 <$> go bound t2)
-    SDef r x ty (Syntax l1 t1) ->
-      SDef r x ty <$> (Syntax l1 <$> go (S.insert x bound) t1)
-    SBind mx (Syntax l1 t1) (Syntax l2 t2) ->
-      SBind mx <$> (Syntax l1 <$> go bound t1) <*> (Syntax l2 <$> go (maybe id S.insert mx bound) t2)
-    SDelay m (Syntax l1 t1) ->
-      SDelay m <$> (Syntax l1 <$> go bound t1)
+      | x `S.member` bound -> pure s
+      | otherwise -> f s
+    SLam x xty s1 -> rewrap $ SLam x xty <$> go (S.insert (lvVar x) bound) s1
+    SApp s1 s2 -> rewrap $ SApp <$> go bound s1 <*> go bound s2
+    SLet r x xty s1 s2 ->
+      let bound' = S.insert (lvVar x) bound
+       in rewrap $ SLet r x xty <$> go bound' s1 <*> go bound' s2
+    SPair s1 s2 -> rewrap $ SPair <$> go bound s1 <*> go bound s2
+    SDef r x xty s1 -> rewrap $ SDef r x xty <$> go (S.insert (lvVar x) bound) s1
+    SBind mx s1 s2 -> rewrap $ SBind mx <$> go bound s1 <*> go (maybe id (S.insert . lvVar) mx bound) s2
+    SDelay m s1 -> rewrap $ SDelay m <$> go bound s1
+   where
+    rewrap s' = Syntax' l <$> s' <*> pure ty
 
--- | Traversal over the free variables of a term.  Note that if you
---   want to get the set of all free variables, you can do so via
---   @'Data.Set.Lens.setOf' 'fv'@.
-fv :: Traversal' Term Var
-fv = fvT . (\f -> \case TVar x -> TVar <$> f x; t -> pure t)
+-- | Like 'freeVarsS', but traverse over the 'Term's containing free
+--   variables.  More direct if you don't need to know the types or
+--   source locations of the variables.  Note that if you want to get
+--   the list of all `Term`s representing free variables, you can do so via
+--   @'toListOf' 'freeVarsT'@.
+freeVarsT :: forall ty. Traversal' (Syntax' ty) (Term' ty)
+freeVarsT = freeVarsS . sTerm
 
--- | Apply a function to all free occurrences of a particular variable.
-mapFree1 :: Var -> (Term -> Term) -> Term -> Term
-mapFree1 x f = fvT %~ (\t -> if t == TVar x then f t else t)
+-- | Traversal over the free variables of a term.  Like 'freeVarsS'
+--   and 'freeVarsT', but traverse over the variable names
+--   themselves.  Note that if you want to get the set of all free
+--   variable names, you can do so via @'Data.Set.Lens.setOf'
+--   'freeVarsV'@.
+freeVarsV :: Traversal' (Syntax' ty) Var
+freeVarsV = freeVarsT . (\f -> \case TVar x -> TVar <$> f x; t -> pure t)
 
-lowShow :: Show a => a -> Text
-lowShow a = toLower (from (show a))
+-- | Apply a function to all free occurrences of a particular variable.
+mapFreeS :: Var -> (Syntax' ty -> Syntax' ty) -> Syntax' ty -> Syntax' ty
+mapFreeS x f = freeVarsS %~ (\t -> case t ^. sTerm of TVar y | y == x -> f t; _ -> t)
diff --git a/src/Swarm/Language/Typecheck.hs b/src/Swarm/Language/Typecheck.hs
--- a/src/Swarm/Language/Typecheck.hs
+++ b/src/Swarm/Language/Typecheck.hs
@@ -1,5 +1,7 @@
+{-# LANGUAGE InstanceSigs #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE ViewPatterns #-}
 {-# OPTIONS_GHC -fno-warn-orphans #-}
 
 -- For 'Ord IntVar' instance
@@ -18,7 +20,7 @@
   -- * Type errors
   TypeErr (..),
   InvalidAtomicReason (..),
-  getTypeErrLocation,
+  getTypeErrSrcLoc,
 
   -- * Inference monad
   Infer,
@@ -46,13 +48,16 @@
 ) where
 
 import Control.Category ((>>>))
+import Control.Lens ((^.))
 import Control.Monad.Except
 import Control.Monad.Reader
 import Control.Unification hiding (applyBindings, (=:=))
 import Control.Unification qualified as U
 import Control.Unification.IntVar
+import Data.Data (Data, gmapM)
 import Data.Foldable (fold)
 import Data.Functor.Identity
+import Data.Generics (mkM)
 import Data.Map (Map)
 import Data.Map qualified as M
 import Data.Maybe
@@ -60,6 +65,7 @@
 import Data.Set qualified as S
 import Swarm.Language.Context hiding (lookup)
 import Swarm.Language.Context qualified as Ctx
+import Swarm.Language.Module
 import Swarm.Language.Parse.QQ (tyQ)
 import Swarm.Language.Syntax
 import Swarm.Language.Types
@@ -79,7 +85,12 @@
 runInfer :: TCtx -> Infer UModule -> Either TypeErr TModule
 runInfer ctx =
   (>>= applyBindings)
-    >>> (>>= \(Module uty uctx) -> Module <$> (fromU <$> generalize uty) <*> pure (fromU uctx))
+    >>> ( >>=
+            \(Module u uctx) ->
+              Module
+                <$> mapM (fmap fromU . generalize) u
+                <*> pure (fromU uctx)
+        )
     >>> flip runReaderT (toU ctx)
     >>> runExceptT
     >>> evalIntBindingT
@@ -89,7 +100,7 @@
 --   an 'UnboundVar' error if it is not found, or opening its
 --   associated 'UPolytype' with fresh unification variables via
 --   'instantiate'.
-lookup :: Location -> Var -> Infer UType
+lookup :: SrcLoc -> Var -> Infer UType
 lookup loc x = do
   ctx <- ask
   maybe (throwError $ UnboundVar loc x) instantiate (Ctx.lookup x ctx)
@@ -142,8 +153,8 @@
 infix 4 =:=
 
 -- | Constrain two types to be equal.
-(=:=) :: UType -> UType -> Infer ()
-s =:= t = void (lift $ s U.=:= t)
+(=:=) :: UType -> UType -> Infer UType
+s =:= t = lift $ s U.=:= t
 
 -- | @unification-fd@ provides a function 'U.applyBindings' which
 --   fully substitutes for any bound unification variables (for
@@ -163,8 +174,14 @@
 instance HasBindings UCtx where
   applyBindings = mapM applyBindings
 
+instance (HasBindings u, Data u) => HasBindings (Term' u) where
+  applyBindings = gmapM (mkM (applyBindings @(Syntax' u)))
+
+instance (HasBindings u, Data u) => HasBindings (Syntax' u) where
+  applyBindings (Syntax' l t u) = Syntax' l <$> applyBindings t <*> applyBindings u
+
 instance HasBindings UModule where
-  applyBindings (Module uty uctx) = Module <$> applyBindings uty <*> applyBindings uctx
+  applyBindings (Module u uctx) = Module <$> applyBindings u <*> applyBindings uctx
 
 ------------------------------------------------------------
 -- Converting between mono- and polytypes
@@ -212,25 +229,25 @@
 --   separately be pretty-printed to display them to the user.
 data TypeErr
   = -- | An undefined variable was encountered.
-    UnboundVar Location Var
+    UnboundVar SrcLoc Var
   | -- | A Skolem variable escaped its local context.
-    EscapedSkolem Location Var
+    EscapedSkolem SrcLoc Var
   | Infinite IntVar UType
   | -- | The given term was expected to have a certain type, but has a
     -- different type instead.
-    Mismatch Location (TypeF UType) (TypeF UType)
+    Mismatch SrcLoc (TypeF UType) (TypeF UType)
   | -- | A definition was encountered not at the top level.
-    DefNotTopLevel Location Term
+    DefNotTopLevel SrcLoc Term
   | -- | A term was encountered which we cannot infer the type of.
     --   This should never happen.
-    CantInfer Location Term
+    CantInfer SrcLoc Term
   | -- | An invalid argument was provided to @atomic@.
-    InvalidAtomic Location InvalidAtomicReason Term
+    InvalidAtomic SrcLoc InvalidAtomicReason Term
   deriving (Show)
 
 -- | Various reasons the body of an @atomic@ might be invalid.
 data InvalidAtomicReason
-  = -- | The arugment has too many tangible commands.
+  = -- | The argument has too many tangible commands.
     TooManyTicks Int
   | -- | The argument uses some way to duplicate code: @def@, @let@, or lambda.
     AtomicDupingThing
@@ -246,8 +263,8 @@
   occursFailure = Infinite
   mismatchFailure = Mismatch NoLoc
 
-getTypeErrLocation :: TypeErr -> Maybe Location
-getTypeErrLocation te = case te of
+getTypeErrSrcLoc :: TypeErr -> Maybe SrcLoc
+getTypeErrSrcLoc te = case te of
   UnboundVar l _ -> Just l
   EscapedSkolem l _ -> Just l
   Infinite _ _ -> Nothing
@@ -268,33 +285,33 @@
 -- | Infer the signature of a top-level expression which might
 --   contain definitions.
 inferModule :: Syntax -> Infer UModule
-inferModule s@(Syntax _ t) = (`catchError` addLocToTypeErr s) $ case t of
+inferModule s@(Syntax l t) = (`catchError` addLocToTypeErr s) $ case t of
   -- For definitions with no type signature, make up a fresh type
   -- variable for the body, infer the body under an extended context,
   -- and unify the two.  Then generalize the type and return an
   -- appropriate context.
-  SDef _ x Nothing t1 -> do
+  SDef r x Nothing t1 -> do
     xTy <- fresh
-    ty <- withBinding x (Forall [] xTy) $ infer t1
-    xTy =:= ty
-    pty <- generalize ty
-    return $ Module (UTyCmd UTyUnit) (singleton x pty)
+    t1' <- withBinding (lvVar x) (Forall [] xTy) $ infer t1
+    _ <- xTy =:= t1' ^. sType
+    pty <- generalize (t1' ^. sType)
+    return $ Module (Syntax' l (SDef r x Nothing t1') (UTyCmd UTyUnit)) (singleton (lvVar x) pty)
 
   -- If a (poly)type signature has been provided, skolemize it and
   -- check the definition.
-  SDef _ x (Just pty) t1 -> do
+  SDef r x (Just pty) t1 -> do
     let upty = toU pty
     uty <- skolemize upty
-    withBinding x upty $ check t1 uty
-    return $ Module (UTyCmd UTyUnit) (singleton x upty)
+    t1' <- withBinding (lvVar x) upty $ check t1 uty
+    return $ Module (Syntax' l (SDef r x (Just pty) t1') (UTyCmd UTyUnit)) (singleton (lvVar x) upty)
 
   -- To handle a 'TBind', infer the types of both sides, combining the
   -- returned modules appropriately.  Have to be careful to use the
   -- correct context when checking the right-hand side in particular.
   SBind mx c1 c2 -> do
     -- First, infer the left side.
-    Module cmda ctx1 <- inferModule c1
-    a <- decomposeCmdTy cmda
+    Module c1' ctx1 <- inferModule c1
+    a <- decomposeCmdTy (c1' ^. sType)
 
     -- Now infer the right side under an extended context: things in
     -- scope on the right-hand side include both any definitions
@@ -304,47 +321,55 @@
     -- case the bound x should shadow the defined one; hence, we apply
     -- that binding /after/ (i.e. /within/) the application of @ctx1@.
     withBindings ctx1 $
-      maybe id (`withBinding` Forall [] a) mx $ do
-        Module cmdb ctx2 <- inferModule c2
+      maybe id ((`withBinding` Forall [] a) . lvVar) mx $ do
+        Module c2' ctx2 <- inferModule c2
 
-        -- We don't actually need the result type since we're just going
-        -- to return cmdb, but it's important to ensure it's a command
-        -- type anyway.  Otherwise something like 'move; 3' would be
-        -- accepted with type int.
-        _ <- decomposeCmdTy cmdb
+        -- We don't actually need the result type since we're just
+        -- going to return the entire type, but it's important to
+        -- ensure it's a command type anyway.  Otherwise something
+        -- like 'move; 3' would be accepted with type int.
+        _ <- decomposeCmdTy (c2' ^. sType)
 
         -- Ctx.union is right-biased, so ctx1 `union` ctx2 means later
         -- definitions will shadow previous ones.  Include the binder
         -- (if any) as well, since binders are made available at the top
         -- level, just like definitions. e.g. if the user writes `r <- build {move}`,
         -- then they will be able to refer to r again later.
-        let ctxX = maybe Ctx.empty (`Ctx.singleton` Forall [] a) mx
-        return $ Module cmdb (ctx1 `Ctx.union` ctxX `Ctx.union` ctx2)
+        let ctxX = maybe Ctx.empty ((`Ctx.singleton` Forall [] a) . lvVar) mx
+        return $
+          Module
+            (Syntax' l (SBind mx c1' c2') (c2' ^. sType))
+            (ctx1 `Ctx.union` ctxX `Ctx.union` ctx2)
 
   -- In all other cases, there can no longer be any definitions in the
   -- term, so delegate to 'infer'.
   _anyOtherTerm -> trivMod <$> infer s
 
--- | Infer the type of a term which does not contain definitions.
-infer :: Syntax -> Infer UType
+-- | Infer the type of a term which does not contain definitions,
+--   returning a type-annotated term.
+infer :: Syntax -> Infer (Syntax' UType)
 infer s@(Syntax l t) = (`catchError` addLocToTypeErr s) $ case t of
-  TUnit -> return UTyUnit
-  TConst c -> instantiate . toU $ inferConst c
-  TDir _ -> return UTyDir
-  TInt _ -> return UTyInt
-  TAntiInt _ -> return UTyInt
-  TText _ -> return UTyText
-  TAntiText _ -> return UTyText
-  TBool _ -> return UTyBool
-  TRobot _ -> return UTyRobot
+  TUnit -> return $ Syntax' l TUnit UTyUnit
+  TConst c -> Syntax' l (TConst c) <$> (instantiate . toU $ inferConst c)
+  TDir d -> return $ Syntax' l (TDir d) UTyDir
+  TInt n -> return $ Syntax' l (TInt n) UTyInt
+  TAntiInt x -> return $ Syntax' l (TAntiInt x) UTyInt
+  TText x -> return $ Syntax' l (TText x) UTyText
+  TAntiText x -> return $ Syntax' l (TAntiText x) UTyText
+  TBool b -> return $ Syntax' l (TBool b) UTyBool
+  TRobot r -> return $ Syntax' l (TRobot r) UTyActor
   -- We should never encounter a TRef since they do not show up in
   -- surface syntax, only as values while evaluating (*after*
   -- typechecking).
   TRef _ -> throwError $ CantInfer l t
-  TRequireDevice _ -> return $ UTyCmd UTyUnit
-  TRequire _ _ -> return $ UTyCmd UTyUnit
+  TRequireDevice d -> return $ Syntax' l (TRequireDevice d) (UTyCmd UTyUnit)
+  TRequire n d -> return $ Syntax' l (TRequire n d) (UTyCmd UTyUnit)
   -- To infer the type of a pair, just infer both components.
-  SPair t1 t2 -> UTyProd <$> infer t1 <*> infer t2
+  SPair t1 t2 -> do
+    t1' <- infer t1
+    t2' <- infer t2
+    return $ Syntax' l (SPair t1' t2') (UTyProd (t1' ^. sType) (t2' ^. sType))
+
   -- if t : ty, then  {t} : {ty}.
   -- Note that in theory, if the @Maybe Var@ component of the @SDelay@
   -- is @Just@, we should typecheck the body under a context extended
@@ -354,74 +379,83 @@
   -- @SDelay@ nodes are never generated from the surface syntax, only
   -- dynamically at runtime when evaluating recursive let or def expressions,
   -- so we don't have to worry about typechecking them here.
-  SDelay _ dt -> UTyDelay <$> infer dt
+  SDelay d t1 -> do
+    t1' <- infer t1
+    return $ Syntax' l (SDelay d t1') (UTyDelay (t1' ^. sType))
+
   -- We need a special case for checking the argument to 'atomic'.
   -- 'atomic t' has the same type as 't', which must have a type of
   -- the form 'cmd a'.  't' must also be syntactically free of
   -- variables.
+
   TConst Atomic :$: at -> do
     argTy <- fresh
-    check at (UTyCmd argTy)
+    at' <- check at (UTyCmd argTy)
+    atomic' <- infer (Syntax l (TConst Atomic))
     -- It's important that we typecheck the subterm @at@ *before* we
     -- check that it is a valid argument to @atomic@: this way we can
     -- ensure that we have already inferred the types of any variables
     -- referenced.
     validAtomic at
-    return $ UTyCmd argTy
+    return $ Syntax' l (SApp atomic' at') (at' ^. sType)
 
   -- Just look up variables in the context.
-  TVar x -> lookup l x
+  TVar x -> Syntax' l (TVar x) <$> lookup l x
   -- To infer the type of a lambda if the type of the argument is
   -- provided, just infer the body under an extended context and return
   -- the appropriate function type.
   SLam x (Just argTy) lt -> do
     let uargTy = toU argTy
-    resTy <- withBinding x (Forall [] uargTy) $ infer lt
-    return $ UTyFun uargTy resTy
+    lt' <- withBinding (lvVar x) (Forall [] uargTy) $ infer lt
+    return $ Syntax' l (SLam x (Just argTy) lt') (UTyFun uargTy (lt' ^. sType))
 
   -- If the type of the argument is not provided, create a fresh
   -- unification variable for it and proceed.
   SLam x Nothing lt -> do
     argTy <- fresh
-    resTy <- withBinding x (Forall [] argTy) $ infer lt
-    return $ UTyFun argTy resTy
+    lt' <- withBinding (lvVar x) (Forall [] argTy) $ infer lt
+    return $ Syntax' l (SLam x Nothing lt') (UTyFun argTy (lt' ^. sType))
 
   -- To infer the type of an application:
   SApp f x -> do
     -- Infer the type of the left-hand side and make sure it has a function type.
-    fTy <- infer f
-    (ty1, ty2) <- decomposeFunTy fTy
+    f' <- infer f
+    (ty1, ty2) <- decomposeFunTy (f' ^. sType)
 
     -- Then check that the argument has the right type.
-    check x ty1 `catchError` addLocToTypeErr x
-    return ty2
+    x' <- check x ty1 `catchError` addLocToTypeErr x
+    return $ Syntax' l (SApp f' x') ty2
 
   -- We can infer the type of a let whether a type has been provided for
   -- the variable or not.
-  SLet _ x Nothing t1 t2 -> do
+  SLet r x Nothing t1 t2 -> do
     xTy <- fresh
-    uty <- withBinding x (Forall [] xTy) $ infer t1
-    xTy =:= uty
+    t1' <- withBinding (lvVar x) (Forall [] xTy) $ infer t1
+    let uty = t1' ^. sType
+    _ <- xTy =:= uty
     upty <- generalize uty
-    withBinding x upty $ infer t2
-  SLet _ x (Just pty) t1 t2 -> do
+    t2' <- withBinding (lvVar x) upty $ infer t2
+    return $ Syntax' l (SLet r x Nothing t1' t2') (t2' ^. sType)
+  SLet r x (Just pty) t1 t2 -> do
     let upty = toU pty
     -- If an explicit polytype has been provided, skolemize it and check
     -- definition and body under an extended context.
     uty <- skolemize upty
-    resTy <- withBinding x upty $ do
-      check t1 uty `catchError` addLocToTypeErr t1
-      infer t2
+    (t1', t2') <- withBinding (lvVar x) upty $ do
+      (,)
+        <$> check t1 uty
+        `catchError` addLocToTypeErr t1
+        <*> infer t2
     -- Make sure no skolem variables have escaped.
     ask >>= mapM_ noSkolems
-    return resTy
+    return $ Syntax' l (SLet r x (Just pty) t1' t2') (t2' ^. sType)
   SDef {} -> throwError $ DefNotTopLevel l t
   SBind mx c1 c2 -> do
-    ty1 <- infer c1
-    a <- decomposeCmdTy ty1
-    ty2 <- maybe id (`withBinding` Forall [] a) mx $ infer c2
-    _ <- decomposeCmdTy ty2
-    return ty2
+    c1' <- infer c1
+    a <- decomposeCmdTy (c1' ^. sType)
+    c2' <- maybe id ((`withBinding` Forall [] a) . lvVar) mx $ infer c2
+    _ <- decomposeCmdTy (c2' ^. sType)
+    return $ Syntax' l (SBind mx c1' c2') (c2' ^. sType)
  where
   noSkolems :: UPolytype -> Infer ()
   noSkolems (Forall xs upty) = do
@@ -433,11 +467,12 @@
             upty'
         ftyvs = tyvs `S.difference` S.fromList xs
     unless (S.null ftyvs) $
-      throwError $ EscapedSkolem l (head (S.toList ftyvs))
+      throwError $
+        EscapedSkolem l (head (S.toList ftyvs))
 
-addLocToTypeErr :: Syntax -> TypeErr -> Infer a
+addLocToTypeErr :: Syntax' ty -> TypeErr -> Infer a
 addLocToTypeErr s te = case te of
-  Mismatch NoLoc a b -> throwError $ Mismatch (sLoc s) a b
+  Mismatch NoLoc a b -> throwError $ Mismatch (s ^. sLoc) a b
   _ -> throwError te
 
 -- | Decompose a type that is supposed to be a command type.
@@ -445,7 +480,7 @@
 decomposeCmdTy (UTyCmd a) = return a
 decomposeCmdTy ty = do
   a <- fresh
-  ty =:= UTyCmd a
+  _ <- ty =:= UTyCmd a
   return a
 
 -- | Decompose a type that is supposed to be a function type.
@@ -454,7 +489,7 @@
 decomposeFunTy ty = do
   ty1 <- fresh
   ty2 <- fresh
-  ty =:= UTyFun ty1 ty2
+  _ <- ty =:= UTyFun ty1 ty2
   return (ty1, ty2)
 
 -- | Infer the type of a constant.
@@ -468,31 +503,36 @@
   Grab -> [tyQ| cmd text |]
   Harvest -> [tyQ| cmd text |]
   Place -> [tyQ| text -> cmd unit |]
-  Give -> [tyQ| robot -> text -> cmd unit |]
-  Install -> [tyQ| robot -> text -> cmd unit |]
+  Give -> [tyQ| actor -> text -> cmd unit |]
+  Equip -> [tyQ| text -> cmd unit |]
+  Unequip -> [tyQ| text -> cmd unit |]
   Make -> [tyQ| text -> cmd unit |]
   Has -> [tyQ| text -> cmd bool |]
-  Installed -> [tyQ| text -> cmd bool |]
+  Equipped -> [tyQ| text -> cmd bool |]
   Count -> [tyQ| text -> cmd int |]
-  Reprogram -> [tyQ| robot -> {cmd a} -> cmd unit |]
-  Build -> [tyQ| {cmd a} -> cmd robot |]
+  Reprogram -> [tyQ| actor -> {cmd a} -> cmd unit |]
+  Build -> [tyQ| {cmd a} -> cmd actor |]
   Drill -> [tyQ| dir -> cmd unit |]
   Salvage -> [tyQ| cmd unit |]
   Say -> [tyQ| text -> cmd unit |]
   Listen -> [tyQ| cmd text |]
   Log -> [tyQ| text -> cmd unit |]
-  View -> [tyQ| robot -> cmd unit |]
+  View -> [tyQ| actor -> cmd unit |]
   Appear -> [tyQ| text -> cmd unit |]
   Create -> [tyQ| text -> cmd unit |]
   Time -> [tyQ| cmd int |]
   Whereami -> [tyQ| cmd (int * int) |]
+  Heading -> [tyQ| cmd dir |]
   Blocked -> [tyQ| cmd bool |]
   Scan -> [tyQ| dir -> cmd (unit + text) |]
-  Upload -> [tyQ| robot -> cmd unit |]
+  Upload -> [tyQ| actor -> cmd unit |]
   Ishere -> [tyQ| text -> cmd bool |]
-  Self -> [tyQ| robot |]
-  Parent -> [tyQ| robot |]
-  Base -> [tyQ| robot |]
+  Isempty -> [tyQ| cmd bool |]
+  Self -> [tyQ| actor |]
+  Parent -> [tyQ| actor |]
+  Base -> [tyQ| actor |]
+  Meet -> [tyQ| cmd (unit + actor) |]
+  MeetAll -> [tyQ| (b -> actor -> cmd b) -> b -> cmd b |]
   Whoami -> [tyQ| cmd text |]
   Setname -> [tyQ| text -> cmd unit |]
   Random -> [tyQ| int -> cmd int |]
@@ -527,24 +567,27 @@
   Concat -> [tyQ| text -> text -> text |]
   Chars -> [tyQ| text -> int |]
   Split -> [tyQ| int -> text -> (text * text) |]
+  CharAt -> [tyQ| int -> text -> int |]
+  ToChar -> [tyQ| int -> text |]
   AppF -> [tyQ| (a -> b) -> a -> b |]
   Swap -> [tyQ| text -> cmd text |]
   Atomic -> [tyQ| cmd a -> cmd a |]
-  Teleport -> [tyQ| robot -> (int * int) -> cmd unit |]
-  As -> [tyQ| robot -> {cmd a} -> cmd a |]
-  RobotNamed -> [tyQ| text -> cmd robot |]
-  RobotNumbered -> [tyQ| int -> cmd robot |]
+  Teleport -> [tyQ| actor -> (int * int) -> cmd unit |]
+  As -> [tyQ| actor -> {cmd a} -> cmd a |]
+  RobotNamed -> [tyQ| text -> cmd actor |]
+  RobotNumbered -> [tyQ| int -> cmd actor |]
   Knows -> [tyQ| text -> cmd bool |]
  where
   cmpBinT = [tyQ| a -> a -> bool |]
   arithBinT = [tyQ| int -> int -> int |]
 
--- | @check t ty@ checks that @t@ has type @ty@.
-check :: Syntax -> UType -> Infer ()
+-- | @check t ty@ checks that @t@ has type @ty@, returning a
+--   type-annotated AST if so.
+check :: Syntax -> UType -> Infer (Syntax' UType)
 check t ty = do
-  ty' <- infer t
-  _ <- ty =:= ty'
-  return ()
+  Syntax' l t' ty' <- infer t
+  theTy <- ty =:= ty'
+  return $ Syntax' l t' theTy
 
 -- | Ensure a term is a valid argument to @atomic@.  Valid arguments
 --   may not contain @def@, @let@, or lambda. Any variables which are
@@ -614,37 +657,37 @@
   SDelay _ s1 -> analyzeAtomic locals s1
   -- Bind is similarly simple except that we have to keep track of a local variable
   -- bound in the RHS.
-  SBind mx s1 s2 -> (+) <$> analyzeAtomic locals s1 <*> analyzeAtomic (maybe id S.insert mx locals) s2
+  SBind mx s1 s2 -> (+) <$> analyzeAtomic locals s1 <*> analyzeAtomic (maybe id (S.insert . lvVar) mx locals) s2
   -- Variables are allowed if bound locally, or if they have a simple type.
   TVar x
     | x `S.member` locals -> return 0
     | otherwise -> do
-      mxTy <- asks $ Ctx.lookup x
-      case mxTy of
-        -- If the variable is undefined, return 0 to indicate the
-        -- atomic block is valid, because we'd rather have the error
-        -- caught by the real name+type checking.
-        Nothing -> return 0
-        Just xTy -> do
-          -- Use applyBindings to make sure that we apply as much
-          -- information as unification has learned at this point.  In
-          -- theory, continuing to typecheck other terms elsewhere in
-          -- the program could give us further information about xTy,
-          -- so we might have incomplete information at this point.
-          -- However, since variables referenced in an atomic block
-          -- must necessarily have simple types, it's unlikely this
-          -- will really make a difference.  The alternative, more
-          -- "correct" way to do this would be to simply emit some
-          -- constraints at this point saying that xTy must be a
-          -- simple type, and check later that the constraint holds,
-          -- after performing complete type inference.  However, since
-          -- the current approach is much simpler, we'll stick with
-          -- this until such time as we have concrete examples showing
-          -- that the more correct, complex way is necessary.
-          xTy' <- applyBindings xTy
-          if isSimpleUPolytype xTy'
-            then return 0
-            else throwError (InvalidAtomic l (NonSimpleVarType x xTy') t)
+        mxTy <- asks $ Ctx.lookup x
+        case mxTy of
+          -- If the variable is undefined, return 0 to indicate the
+          -- atomic block is valid, because we'd rather have the error
+          -- caught by the real name+type checking.
+          Nothing -> return 0
+          Just xTy -> do
+            -- Use applyBindings to make sure that we apply as much
+            -- information as unification has learned at this point.  In
+            -- theory, continuing to typecheck other terms elsewhere in
+            -- the program could give us further information about xTy,
+            -- so we might have incomplete information at this point.
+            -- However, since variables referenced in an atomic block
+            -- must necessarily have simple types, it's unlikely this
+            -- will really make a difference.  The alternative, more
+            -- "correct" way to do this would be to simply emit some
+            -- constraints at this point saying that xTy must be a
+            -- simple type, and check later that the constraint holds,
+            -- after performing complete type inference.  However, since
+            -- the current approach is much simpler, we'll stick with
+            -- this until such time as we have concrete examples showing
+            -- that the more correct, complex way is necessary.
+            xTy' <- applyBindings xTy
+            if isSimpleUPolytype xTy'
+              then return 0
+              else throwError (InvalidAtomic l (NonSimpleVarType x xTy') t)
   -- No lambda, `let` or `def` allowed!
   SLam {} -> throwError (InvalidAtomic l AtomicDupingThing t)
   SLet {} -> throwError (InvalidAtomic l AtomicDupingThing t)
diff --git a/src/Swarm/Language/Types.hs b/src/Swarm/Language/Types.hs
--- a/src/Swarm/Language/Types.hs
+++ b/src/Swarm/Language/Types.hs
@@ -29,7 +29,7 @@
   pattern TyText,
   pattern TyDir,
   pattern TyBool,
-  pattern TyRobot,
+  pattern TyActor,
   pattern (:+:),
   pattern (:*:),
   pattern (:->:),
@@ -46,7 +46,7 @@
   pattern UTyText,
   pattern UTyDir,
   pattern UTyBool,
-  pattern UTyRobot,
+  pattern UTyActor,
   pattern UTySum,
   pattern UTyProd,
   pattern UTyFun,
@@ -67,12 +67,6 @@
   TCtx,
   UCtx,
 
-  -- * Modules
-  Module (..),
-  TModule,
-  UModule,
-  trivMod,
-
   -- * The 'WithU' class
   WithU (..),
 ) where
@@ -111,8 +105,11 @@
     BDir
   | -- | Booleans.
     BBool
-  | -- | Robots.
-    BRobot
+  | -- | "Actors", i.e. anything that can do stuff. Internally, these
+    --   are all just "robots", but we give them a more generic
+    --   in-game name because they could represent other things like
+    --   aliens, animals, seeds, ...
+    BActor
   deriving (Eq, Ord, Show, Data, Generic, FromJSON, ToJSON)
 
 -- | A "structure functor" encoding the shape of type expressions.
@@ -214,34 +211,6 @@
 type UPolytype = Poly UType
 
 ------------------------------------------------------------
--- Modules
-------------------------------------------------------------
-
--- | A module generally represents the result of performing type
---   inference on a top-level expression, which in particular can
---   contain definitions ('Swarm.Language.Syntax.TDef').  A module
---   contains the overall type of the expression, as well as the
---   context giving the types of any defined variables.
-data Module s t = Module {moduleTy :: s, moduleCtx :: Ctx t}
-  deriving (Show, Eq, Functor, Data, Generic, FromJSON, ToJSON)
-
--- | A 'TModule' is the final result of the type inference process on
---   an expression: we get a polytype for the expression, and a
---   context of polytypes for the defined variables.
-type TModule = Module Polytype Polytype
-
--- | A 'UModule' represents the type of an expression at some
---   intermediate stage during the type inference process.  We get a
---   'UType' (/not/ a 'UPolytype') for the expression, which may
---   contain some free unification or type variables, as well as a
---   context of 'UPolytype's for any defined variables.
-type UModule = Module UType UPolytype
-
--- | The trivial module for a given @s@, with the empty context.
-trivMod :: s -> Module s t
-trivMod t = Module t empty
-
-------------------------------------------------------------
 -- WithU
 ------------------------------------------------------------
 
@@ -312,8 +281,8 @@
 pattern TyBool :: Type
 pattern TyBool = Fix (TyBaseF BBool)
 
-pattern TyRobot :: Type
-pattern TyRobot = Fix (TyBaseF BRobot)
+pattern TyActor :: Type
+pattern TyActor = Fix (TyBaseF BActor)
 
 infixr 5 :+:
 
@@ -360,8 +329,8 @@
 pattern UTyBool :: UType
 pattern UTyBool = UTerm (TyBaseF BBool)
 
-pattern UTyRobot :: UType
-pattern UTyRobot = UTerm (TyBaseF BRobot)
+pattern UTyActor :: UType
+pattern UTyActor = UTerm (TyBaseF BActor)
 
 pattern UTySum :: UType -> UType -> UType
 pattern UTySum ty1 ty2 = UTerm (TySumF ty1 ty2)
diff --git a/src/Swarm/Language/Value.hs b/src/Swarm/Language/Value.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Language/Value.hs
@@ -0,0 +1,122 @@
+{-# LANGUAGE DeriveGeneric #-}
+{-# LANGUAGE GADTs #-}
+
+-- |
+-- Module      :  Swarm.Language.Value
+-- Copyright   :  Brent Yorgey
+-- Maintainer  :  byorgey@gmail.com
+--
+-- SPDX-License-Identifier: BSD-3-Clause
+--
+-- Values and environments used for interpreting the Swarm language.
+module Swarm.Language.Value (
+  -- * Values
+  Value (..),
+  stripVResult,
+  prettyValue,
+  valueToTerm,
+
+  -- * Environments
+  Env,
+) where
+
+import Data.Aeson (FromJSON, ToJSON)
+import Data.Bool (bool)
+import Data.List (foldl')
+import Data.Map qualified as M
+import Data.Set qualified as S
+import Data.Set.Lens (setOf)
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Swarm.Language.Context
+import Swarm.Language.Pretty (prettyText)
+import Swarm.Language.Syntax
+import Prelude
+
+-- | A /value/ is a term that cannot (or does not) take any more
+--   evaluation steps on its own.
+data Value where
+  -- | The unit value.
+  VUnit :: Value
+  -- | An integer.
+  VInt :: Integer -> Value
+  -- | Literal text.
+  VText :: Text -> Value
+  -- | A direction.
+  VDir :: Direction -> Value
+  -- | A boolean.
+  VBool :: Bool -> Value
+  -- | A reference to a robot.
+  VRobot :: Int -> Value
+  -- | An injection into a sum type.  False = left, True = right.
+  VInj :: Bool -> Value -> Value
+  -- | A pair.
+  VPair :: Value -> Value -> Value
+  -- | A /closure/, representing a lambda term along with an
+  --   environment containing bindings for any free variables in the
+  --   body of the lambda.
+  VClo :: Var -> Term -> Env -> Value
+  -- | An application of a constant to some value arguments,
+  --   potentially waiting for more arguments.  If a constant
+  --   application is fully saturated (as defined by its 'arity'),
+  --   whether it is a value or not depends on whether or not it
+  --   represents a command (as defined by 'isCmd').  If a command
+  --   (e.g. 'Build'), it is a value, and awaits an 'Swarm.Game.CESK.FExec' frame
+  --   which will cause it to execute.  Otherwise (e.g. 'If'), it is
+  --   not a value, and will immediately reduce.
+  VCApp :: Const -> [Value] -> Value
+  -- | A definition, which does not take effect until executed.
+  --   The @Bool@ indicates whether the definition is recursive.
+  VDef :: Bool -> Var -> Term -> Env -> Value
+  -- | The result of a command, consisting of the result of the
+  --   command as well as an environment of bindings from 'TDef'
+  --   commands.
+  VResult :: Value -> Env -> Value
+  -- | An unevaluated bind expression, waiting to be executed, of the
+  --   form /i.e./ @c1 ; c2@ or @x <- c1; c2@.  We also store an 'Env'
+  --   in which to interpret the commands.
+  VBind :: Maybe Var -> Term -> Term -> Env -> Value
+  -- | A (non-recursive) delayed term, along with its environment. If
+  --   a term would otherwise be evaluated but we don't want it to be
+  --   (/e.g./ as in the case of arguments to an 'if', or a recursive
+  --   binding), we can stick a 'TDelay' on it, which turns it into a
+  --   value.  Delayed terms won't be evaluated until 'Force' is
+  --   applied to them.
+  VDelay :: Term -> Env -> Value
+  -- | A reference to a memory cell in the store.
+  VRef :: Int -> Value
+  deriving (Eq, Show, Generic, FromJSON, ToJSON)
+
+-- | Ensure that a value is not wrapped in 'VResult'.
+stripVResult :: Value -> Value
+stripVResult (VResult v _) = stripVResult v
+stripVResult v = v
+
+-- | Pretty-print a value.
+prettyValue :: Value -> Text
+prettyValue = prettyText . valueToTerm
+
+-- | Inject a value back into a term.
+valueToTerm :: Value -> Term
+valueToTerm VUnit = TUnit
+valueToTerm (VInt n) = TInt n
+valueToTerm (VText s) = TText s
+valueToTerm (VDir d) = TDir d
+valueToTerm (VBool b) = TBool b
+valueToTerm (VRobot r) = TRobot r
+valueToTerm (VInj s v) = TApp (TConst (bool Inl Inr s)) (valueToTerm v)
+valueToTerm (VPair v1 v2) = TPair (valueToTerm v1) (valueToTerm v2)
+valueToTerm (VClo x t e) =
+  M.foldrWithKey
+    (\y v -> TLet False y Nothing (valueToTerm v))
+    (TLam x Nothing t)
+    (M.restrictKeys (unCtx e) (S.delete x (setOf freeVarsV (Syntax' NoLoc t ()))))
+valueToTerm (VCApp c vs) = foldl' TApp (TConst c) (reverse (map valueToTerm vs))
+valueToTerm (VDef r x t _) = TDef r x Nothing t
+valueToTerm (VResult v _) = valueToTerm v
+valueToTerm (VBind mx c1 c2 _) = TBind mx c1 c2
+valueToTerm (VDelay t _) = TDelay SimpleDelay t
+valueToTerm (VRef n) = TRef n
+
+-- | An environment is a mapping from variable names to values.
+type Env = Ctx Value
diff --git a/src/Swarm/TUI/Attr.hs b/src/Swarm/TUI/Attr.hs
--- a/src/Swarm/TUI/Attr.hs
+++ b/src/Swarm/TUI/Attr.hs
@@ -15,7 +15,7 @@
 -- and not those used in the world map, to avoid abusing attributes.
 -- For example using the robot attribute to highlight some text.
 --
--- The few attributes that we use for drawing the logo are an exeption.
+-- The few attributes that we use for drawing the logo are an exception.
 module Swarm.TUI.Attr (
   swarmAttrMap,
   worldAttributes,
@@ -40,7 +40,9 @@
   infoAttr,
   boldAttr,
   dimAttr,
+  magentaAttr,
   cyanAttr,
+  lightCyanAttr,
   yellowAttr,
   blueAttr,
   greenAttr,
@@ -83,6 +85,8 @@
          , (blueAttr, fg V.blue)
          , (yellowAttr, fg V.yellow)
          , (cyanAttr, fg V.cyan)
+         , (lightCyanAttr, fg (V.rgbColor @Int 200 255 255))
+         , (magentaAttr, fg V.magenta)
          , -- Default attribute
            (defAttr, V.defAttr)
          ]
@@ -161,12 +165,14 @@
 defAttr = attrName "def"
 
 -- | Some basic colors used in TUI.
-redAttr, greenAttr, blueAttr, yellowAttr, cyanAttr :: AttrName
+redAttr, greenAttr, blueAttr, yellowAttr, cyanAttr, lightCyanAttr, magentaAttr :: AttrName
 redAttr = attrName "red"
 greenAttr = attrName "green"
 blueAttr = attrName "blue"
 yellowAttr = attrName "yellow"
 cyanAttr = attrName "cyan"
+lightCyanAttr = attrName "lightCyan"
+magentaAttr = attrName "magenta"
 
 instance ToJSON AttrName where
   toJSON = toJSON . head . attrNameComponents
diff --git a/src/Swarm/TUI/Controller.hs b/src/Swarm/TUI/Controller.hs
--- a/src/Swarm/TUI/Controller.hs
+++ b/src/Swarm/TUI/Controller.hs
@@ -1,5 +1,5 @@
 {-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE PatternSynonyms #-}
+{-# LANGUAGE QuasiQuotes #-}
 
 -- |
 -- Module      :  Swarm.TUI.Controller
@@ -38,7 +38,8 @@
   handleInfoPanelEvent,
 ) where
 
-import Brick hiding (Direction)
+import Brick hiding (Direction, Location)
+import Brick qualified
 import Brick.Focus
 import Brick.Widgets.Dialog
 import Brick.Widgets.Edit (handleEditorEvent)
@@ -49,10 +50,12 @@
 import Control.Lens
 import Control.Lens.Extras (is)
 import Control.Monad.Except
+import Control.Monad.Extra (whenJust)
 import Control.Monad.State
 import Data.Bits
 import Data.Either (isRight)
-import Data.Int (Int64)
+import Data.Foldable (toList)
+import Data.Int (Int32)
 import Data.List.NonEmpty (NonEmpty (..))
 import Data.List.NonEmpty qualified as NE
 import Data.Map qualified as M
@@ -61,48 +64,50 @@
 import Data.Text qualified as T
 import Data.Text.IO qualified as T
 import Data.Time (getZonedTime)
+import Data.Vector qualified as V
 import Graphics.Vty qualified as V
 import Linear
 import Swarm.Game.CESK (cancel, emptyStore, initMachine)
 import Swarm.Game.Entity hiding (empty)
 import Swarm.Game.Robot
+import Swarm.Game.Scenario.Objective.Presentation.Model
+import Swarm.Game.Scenario.Objective.Presentation.Render qualified as GR
 import Swarm.Game.ScenarioInfo
 import Swarm.Game.State
 import Swarm.Game.Step (gameTick)
-import Swarm.Game.Value (Value (VUnit), prettyValue)
 import Swarm.Game.World qualified as W
 import Swarm.Language.Capability (Capability (CMake))
 import Swarm.Language.Context
+import Swarm.Language.Module
 import Swarm.Language.Parse (reservedWords)
 import Swarm.Language.Pipeline
+import Swarm.Language.Pipeline.QQ (tmQ)
 import Swarm.Language.Pretty
 import Swarm.Language.Requirement qualified as R
 import Swarm.Language.Syntax
 import Swarm.Language.Typed (Typed (..))
 import Swarm.Language.Types
+import Swarm.Language.Value (Value (VUnit), prettyValue, stripVResult)
+import Swarm.TUI.Controller.Util
 import Swarm.TUI.Inventory.Sorting (cycleSortDirection, cycleSortOrder)
 import Swarm.TUI.List
 import Swarm.TUI.Model
+import Swarm.TUI.Model.Achievement.Definitions
+import Swarm.TUI.Model.Achievement.Persistence
+import Swarm.TUI.Model.Name
+import Swarm.TUI.Model.Repl
+import Swarm.TUI.Model.StateUpdate
+import Swarm.TUI.Model.UI
 import Swarm.TUI.View (generateModal)
 import Swarm.Util hiding ((<<.=))
+import Swarm.Util.Location
 import Swarm.Version (NewReleaseFailure (..))
 import System.Clock
+import System.FilePath (splitDirectories)
 import Witch (into)
 
--- | Pattern synonyms to simplify brick event handler
-pattern Key :: V.Key -> BrickEvent n e
-pattern Key k = VtyEvent (V.EvKey k [])
-
-pattern CharKey, ControlKey, MetaKey :: Char -> BrickEvent n e
-pattern CharKey c = VtyEvent (V.EvKey (V.KChar c) [])
-pattern ControlKey c = VtyEvent (V.EvKey (V.KChar c) [V.MCtrl])
-pattern MetaKey c = VtyEvent (V.EvKey (V.KChar c) [V.MMeta])
-
-pattern EscapeKey :: BrickEvent n e
-pattern EscapeKey = VtyEvent (V.EvKey V.KEsc [])
-
-pattern FKey :: Int -> BrickEvent n e
-pattern FKey c = VtyEvent (V.EvKey (V.KFun c) [])
+tutorialsDirname :: FilePath
+tutorialsDirname = "Tutorials"
 
 -- | The top-level event handler for the TUI.
 handleEvent :: BrickEvent Name AppEvent -> EventM Name AppState ()
@@ -111,8 +116,8 @@
   AppEvent (UpstreamVersion ev) -> do
     let logReleaseEvent l e = runtimeState . eventLog %= logEvent l ("Release", -7) (T.pack $ show e)
     case ev of
-      Left e@(FailedReleaseQuery _e) -> logReleaseEvent ErrorTrace e
-      Left e -> logReleaseEvent Said e
+      Left e@(FailedReleaseQuery _e) -> logReleaseEvent (ErrorTrace Error) e
+      Left e -> logReleaseEvent (ErrorTrace Warning) e
       Right _ -> pure ()
     runtimeState . upstreamRelease .= ev
   e -> do
@@ -128,6 +133,7 @@
           MainMenu l -> handleMainMenuEvent l
           NewGameMenu l -> handleNewGameMenuEvent l
           MessagesMenu -> handleMainMessagesEvent
+          AchievementsMenu l -> handleMainAchievementsEvent l
           AboutMenu -> pressAnyKey (MainMenu (mainMenu About))
 
 -- | The event handler for the main menu.
@@ -149,7 +155,7 @@
           let tutorialCollection = getTutorials ss
               topMenu =
                 BL.listFindBy
-                  ((== "Tutorials") . scenarioItemName)
+                  ((== tutorialsDirname) . T.unpack . scenarioItemName)
                   (mkScenarioList cheat ss)
               tutorialMenu = mkScenarioList cheat tutorialCollection
               menuStack = NE.fromList [tutorialMenu, topMenu]
@@ -162,20 +168,23 @@
                   _ -> error "No first tutorial found!"
                 _ -> error "No first tutorial found!"
           startGame firstTutorial Nothing
+        Achievements -> uiState . uiMenu .= AchievementsMenu (BL.list AchievementList (V.fromList listAchievements) 1)
         Messages -> do
           runtimeState . eventLog . notificationsCount .= 0
           uiState . uiMenu .= MessagesMenu
-        About -> uiState . uiMenu .= AboutMenu
+        About -> do
+          uiState . uiMenu .= AboutMenu
+          attainAchievement $ GlobalAchievement LookedAtAboutScreen
         Quit -> halt
   CharKey 'q' -> halt
-  ControlKey 'q' -> halt
+  ControlChar 'q' -> halt
   VtyEvent ev -> do
     menu' <- nestEventM' menu (handleListEvent ev)
     uiState . uiMenu .= MainMenu menu'
   _ -> continueWithoutRedraw
 
 getTutorials :: ScenarioCollection -> ScenarioCollection
-getTutorials sc = case M.lookup "Tutorials" (scMap sc) of
+getTutorials sc = case M.lookup tutorialsDirname (scMap sc) of
   Just (SICollection _ c) -> c
   _ -> error "No tutorials exist!"
 
@@ -183,11 +192,26 @@
 advanceMenu :: Menu -> Menu
 advanceMenu = _NewGameMenu . ix 0 %~ BL.listMoveDown
 
+handleMainAchievementsEvent ::
+  BL.List Name CategorizedAchievement ->
+  BrickEvent Name AppEvent ->
+  EventM Name AppState ()
+handleMainAchievementsEvent l e = case e of
+  Key V.KEsc -> returnToMainMenu
+  CharKey 'q' -> returnToMainMenu
+  ControlChar 'q' -> returnToMainMenu
+  VtyEvent ev -> do
+    l' <- nestEventM' l (handleListEvent ev)
+    uiState . uiMenu .= AchievementsMenu l'
+  _ -> continueWithoutRedraw
+ where
+  returnToMainMenu = uiState . uiMenu .= MainMenu (mainMenu Messages)
+
 handleMainMessagesEvent :: BrickEvent Name AppEvent -> EventM Name AppState ()
 handleMainMessagesEvent = \case
   Key V.KEsc -> returnToMainMenu
   CharKey 'q' -> returnToMainMenu
-  ControlKey 'q' -> returnToMainMenu
+  ControlChar 'q' -> returnToMainMenu
   _ -> return ()
  where
   returnToMainMenu = uiState . uiMenu .= MainMenu (mainMenu Messages)
@@ -203,7 +227,7 @@
         uiState . uiMenu .= NewGameMenu (NE.cons (mkScenarioList cheat c) scenarioStack)
   Key V.KEsc -> exitNewGameMenu scenarioStack
   CharKey 'q' -> exitNewGameMenu scenarioStack
-  ControlKey 'q' -> halt
+  ControlChar 'q' -> halt
   VtyEvent ev -> do
     menu' <- nestEventM' curMenu (handleListEvent ev)
     uiState . uiMenu .= NewGameMenu (menu' :| rest)
@@ -211,7 +235,8 @@
 
 exitNewGameMenu :: NonEmpty (BL.List Name ScenarioItem) -> EventM Name AppState ()
 exitNewGameMenu stk = do
-  uiState . uiMenu
+  uiState
+    . uiMenu
     .= case snd (NE.uncons stk) of
       Nothing -> MainMenu (mainMenu NewGame)
       Just stk' -> NewGameMenu stk'
@@ -231,19 +256,22 @@
       | s ^. gameState . paused -> continueWithoutRedraw
       | otherwise -> runFrameUI
     -- ctrl-q works everywhere
-    ControlKey 'q' ->
+    ControlChar 'q' ->
       case s ^. gameState . winCondition of
-        Won _ -> toggleModal WinModal
+        WinConditions (Won _) _ -> toggleModal WinModal
+        WinConditions (Unwinnable _) _ -> toggleModal LoseModal
         _ -> toggleModal QuitModal
     VtyEvent (V.EvResize _ _) -> invalidateCacheEntry WorldCache
     Key V.KEsc
       | isJust (s ^. uiState . uiError) -> uiState . uiError .= Nothing
       | Just m <- s ^. uiState . uiModal -> do
-        safeAutoUnpause
-        uiState . uiModal .= Nothing
-        -- message modal is not autopaused, so update notifications when leaving it
-        when (m ^. modalType == MessagesModal) $ do
-          gameState . lastSeenMessageTime .= s ^. gameState . ticks
+          safeAutoUnpause
+          uiState . uiModal .= Nothing
+          -- message modal is not autopaused, so update notifications when leaving it
+          case m ^. modalType of
+            MessagesModal -> do
+              gameState . lastSeenMessageTime .= s ^. gameState . ticks
+            _ -> return ()
     FKey 1 -> toggleModal HelpModal
     FKey 2 -> toggleModal RobotsModal
     FKey 3 | not (null (s ^. gameState . availableRecipes . notificationsContent)) -> do
@@ -255,10 +283,11 @@
     FKey 5 | not (null (s ^. gameState . messageNotifications . notificationsContent)) -> do
       toggleModal MessagesModal
       gameState . lastSeenMessageTime .= s ^. gameState . ticks
-    ControlKey 'g' -> case s ^. uiState . uiGoal of
-      Just g | g /= [] -> toggleModal (GoalModal g)
-      _ -> continueWithoutRedraw
-    MetaKey 'h' -> do
+    ControlChar 'g' ->
+      if hasAnythingToShow $ s ^. uiState . uiGoal . goalsContent
+        then toggleModal GoalModal
+        else continueWithoutRedraw
+    MetaChar 'h' -> do
       t <- liftIO $ getTime Monotonic
       h <- use $ uiState . uiHideRobotsUntil
       if h >= t
@@ -269,54 +298,57 @@
           uiState . uiHideRobotsUntil .= t + TimeSpec 2 0
           invalidateCacheEntry WorldCache
     -- pausing and stepping
-    ControlKey 'p' | isRunning -> safeTogglePause
-    ControlKey 'o' | isRunning -> do
+    ControlChar 'p' | isRunning -> safeTogglePause
+    ControlChar 'o' | isRunning -> do
       gameState . runStatus .= ManualPause
       runGameTickUI
     -- speed controls
-    ControlKey 'x' | isRunning -> modify $ adjustTPS (+)
-    ControlKey 'z' | isRunning -> modify $ adjustTPS (-)
+    ControlChar 'x' | isRunning -> modify $ adjustTPS (+)
+    ControlChar 'z' | isRunning -> modify $ adjustTPS (-)
     -- special keys that work on all panels
-    MetaKey 'w' -> setFocus WorldPanel
-    MetaKey 'e' -> setFocus RobotPanel
-    MetaKey 'r' -> setFocus REPLPanel
-    MetaKey 't' -> setFocus InfoPanel
+    MetaChar 'w' -> setFocus WorldPanel
+    MetaChar 'e' -> setFocus RobotPanel
+    MetaChar 'r' -> setFocus REPLPanel
+    MetaChar 't' -> setFocus InfoPanel
     -- pass keys on to modal event handler if a modal is open
     VtyEvent vev
       | isJust (s ^. uiState . uiModal) -> handleModalEvent vev
     -- toggle creative mode if in "cheat mode"
-    ControlKey 'v'
+    ControlChar 'v'
       | s ^. uiState . uiCheatMode -> gameState . creativeMode %= not
     MouseDown n _ _ mouseLoc ->
       case n of
-        WorldPanel -> do
+        FocusablePanel WorldPanel -> do
           mouseCoordsM <- Brick.zoom gameState (mouseLocToWorldCoords mouseLoc)
           uiState . uiWorldCursor .= mouseCoordsM
-        REPLInput -> do
-          setFocus REPLPanel
-          handleREPLEvent ev
+        REPLInput -> handleREPLEvent ev
         _ -> continueWithoutRedraw
     MouseUp n _ _mouseLoc -> do
       case n of
         InventoryListItem pos -> uiState . uiInventory . traverse . _2 %= BL.listMoveTo pos
         _ -> return ()
-      setFocus $ case n of
+      flip whenJust setFocus $ case n of
         -- Adapt click event origin to their right panel.
         -- For the REPL and the World view, using 'Brick.Widgets.Core.clickable' correctly set the origin.
         -- However this does not seems to work for the robot and info panel.
         -- Thus we force the destination focus here.
-        InventoryList -> RobotPanel
-        InventoryListItem _ -> RobotPanel
-        InfoViewport -> InfoPanel
-        _ -> n
+        InventoryList -> Just RobotPanel
+        InventoryListItem _ -> Just RobotPanel
+        InfoViewport -> Just InfoPanel
+        REPLInput -> Just REPLPanel
+        _ -> Nothing
+      case n of
+        FocusablePanel x -> setFocus x
+        _ -> return ()
     -- dispatch any other events to the focused panel handler
     _ev -> do
       fring <- use $ uiState . uiFocusRing
       case focusGetCurrent fring of
-        Just REPLPanel -> handleREPLEvent ev
-        Just WorldPanel -> handleWorldEvent ev
-        Just RobotPanel -> handleRobotPanelEvent ev
-        Just InfoPanel -> handleInfoPanelEvent infoScroll ev
+        Just (FocusablePanel x) -> ($ ev) $ case x of
+          REPLPanel -> handleREPLEvent
+          WorldPanel -> handleWorldEvent
+          RobotPanel -> handleRobotPanelEvent
+          InfoPanel -> handleInfoPanelEvent infoScroll
         _ -> continueWithoutRedraw
 
 mouseLocToWorldCoords :: Brick.Location -> EventM Name GameState (Maybe W.Coords)
@@ -332,9 +364,6 @@
           my = fst mouseLoc' + snd regionStart
        in pure . Just $ W.Coords (mx, my)
 
-setFocus :: Name -> EventM Name AppState ()
-setFocus name = uiState . uiFocusRing %= focusSetCurrent name
-
 -- | Set the game to Running if it was (auto) paused otherwise to paused.
 --
 -- Also resets the last frame time to now. If we are pausing, it
@@ -360,39 +389,39 @@
 toggleModal mt = do
   modal <- use $ uiState . uiModal
   case modal of
-    Nothing -> do
-      newModal <- gets $ flip generateModal mt
-      ensurePause
-      uiState . uiModal ?= newModal
+    Nothing -> openModal mt
     Just _ -> uiState . uiModal .= Nothing >> safeAutoUnpause
- where
-  -- Set the game to AutoPause if needed
-  ensurePause = do
-    pause <- use $ gameState . paused
-    unless (pause || isRunningModal mt) $ do
-      gameState . runStatus .= AutoPause
 
--- | The running modals do not autopause the game.
-isRunningModal :: ModalType -> Bool
-isRunningModal mt = mt `elem` [RobotsModal, MessagesModal]
-
 handleModalEvent :: V.Event -> EventM Name AppState ()
 handleModalEvent = \case
   V.EvKey V.KEnter [] -> do
     mdialog <- preuse $ uiState . uiModal . _Just . modalDialog
     toggleModal QuitModal
-    case dialogSelection <$> mdialog of
-      Just (Just QuitButton) -> quitGame
-      Just (Just KeepPlayingButton) -> toggleModal KeepPlayingModal
-      Just (Just (StartOverButton currentSeed siPair)) -> restartGame currentSeed siPair
-      Just (Just (NextButton siPair)) -> saveScenarioInfoOnQuit >> startGame siPair Nothing
+    case dialogSelection =<< mdialog of
+      Just (Button QuitButton, _) -> quitGame
+      Just (Button KeepPlayingButton, _) -> toggleModal KeepPlayingModal
+      Just (Button StartOverButton, StartOver currentSeed siPair) -> restartGame currentSeed siPair
+      Just (Button NextButton, Next siPair) -> saveScenarioInfoOnQuit >> startGame siPair Nothing
       _ -> return ()
   ev -> do
     Brick.zoom (uiState . uiModal . _Just . modalDialog) (handleDialogEvent ev)
     modal <- preuse $ uiState . uiModal . _Just . modalType
     case modal of
-      Just _ -> handleInfoPanelEvent modalScroll (VtyEvent ev)
+      Just GoalModal -> case ev of
+        V.EvKey (V.KChar '\t') [] -> uiState . uiGoal . focus %= focusNext
+        _ -> do
+          focused <- use $ uiState . uiGoal . focus
+          case focusGetCurrent focused of
+            Just (GoalWidgets w) -> case w of
+              ObjectivesList -> do
+                lw <- use $ uiState . uiGoal . listWidget
+                newList <- refreshList lw
+                uiState . uiGoal . listWidget .= newList
+              GoalSummary -> handleInfoPanelEvent modalScroll (VtyEvent ev)
+            _ -> handleInfoPanelEvent modalScroll (VtyEvent ev)
       _ -> return ()
+   where
+    refreshList lw = nestEventM' lw $ handleListEventWithSeparators ev isHeader
 
 -- | Write the @ScenarioInfo@ out to disk when exiting a game.
 saveScenarioInfoOnQuit :: (MonadIO m, MonadState AppState m) => m ()
@@ -408,7 +437,10 @@
         gs <- use $ gameState . scenarios
         p <- liftIO $ normalizeScenarioPath gs p'
         t <- liftIO getZonedTime
-        won <- isJust <$> preuse (gameState . winCondition . _Won)
+        wc <- use $ gameState . winCondition
+        let won = case wc of
+              WinConditions (Won _) _ -> True
+              _ -> False
         ts <- use $ gameState . ticks
         let currentScenarioInfo :: Traversal' AppState ScenarioInfo
             currentScenarioInfo = gameState . scenarios . scenarioItemByPath p . _SISingle . _2
@@ -416,7 +448,14 @@
         status <- preuse currentScenarioInfo
         case status of
           Nothing -> return ()
-          Just si -> liftIO $ saveScenarioInfo p si
+          Just si -> do
+            let segments = splitDirectories p
+            case segments of
+              firstDir : _ -> do
+                when (won && firstDir == tutorialsDirname) $
+                  attainAchievement' t (Just p) (GlobalAchievement CompletedSingleTutorial)
+              _ -> return ()
+            liftIO $ saveScenarioInfo p si
 
         -- See what scenario is currently focused in the menu.  Depending on how the
         -- previous scenario ended (via quit vs. via win), it might be the same as
@@ -556,12 +595,30 @@
   gs' <- liftIO (Fused.runM (Fused.execState gs f))
   gameState .= gs'
 
+updateAchievements :: EventM Name AppState ()
+updateAchievements = do
+  -- Merge the in-game achievements with the master list in UIState
+  achievementsFromGame <- use $ gameState . gameAchievements
+  let wrappedGameAchievements = M.mapKeys GameplayAchievement achievementsFromGame
+
+  oldMasterAchievementsList <- use $ uiState . uiAchievements
+  uiState . uiAchievements %= M.unionWith (<>) wrappedGameAchievements
+
+  -- Don't save to disk unless there was a change in the attainment list.
+  let incrementalAchievements = wrappedGameAchievements `M.difference` oldMasterAchievementsList
+  unless (null incrementalAchievements) $ do
+    -- TODO: #916 This is where new achievements would be displayed in a popup
+    newAchievements <- use $ uiState . uiAchievements
+    liftIO $ saveAchievementsInfo $ M.elems newAchievements
+
 -- | Run the game for a single tick (/without/ updating the UI).
 --   Every robot is given a certain amount of maximum computation to
 --   perform a single world action (like moving, turning, grabbing,
 --   etc.).
 runGameTick :: EventM Name AppState ()
-runGameTick = zoomGameState gameTick
+runGameTick = do
+  zoomGameState gameTick
+  updateAchievements
 
 -- | Update the UI.  This function is used after running the
 --   game for some number of ticks.
@@ -605,7 +662,7 @@
     -- result as a REPL output, with its type, and reset the replStatus.
     REPLWorking (Typed (Just v) pty reqs) -> do
       let finalType = stripCmd pty
-      let val = Typed v finalType reqs
+      let val = Typed (stripVResult v) finalType reqs
       itIx <- use (gameState . replNextValueIndex)
       let itName = fromString $ "it" ++ show itIx
       let out = T.intercalate " " [itName, ":", prettyText finalType, "=", into (prettyValue v)]
@@ -618,19 +675,29 @@
     -- Otherwise, do nothing.
     _ -> pure False
 
-  -- If the focused robot's log has been updated, attempt to
-  -- automatically switch to it and scroll all the way down so the new
-  -- message can be seen.
+  -- If the focused robot's log has been updated and the UI focus
+  -- isn't currently on the inventory or info panels, attempt to
+  -- automatically switch to the logger and scroll all the way down so
+  -- the new message can be seen.
   uiState . uiScrollToEnd .= False
   logUpdated <- do
-    case maybe False (view robotLogUpdated) fr of
+    -- If the inventory or info panels are currently focused, it would
+    -- be rude to update them right under the user's nose, so consider
+    -- them "sticky".  They will be updated as soon as the player moves
+    -- the focus away.
+    fring <- use $ uiState . uiFocusRing
+    let sticky = focusGetCurrent fring `elem` map (Just . FocusablePanel) [RobotPanel, InfoPanel]
+
+    -- Check if the robot log was updated and we are allowed to change
+    -- the inventory+info panels.
+    case maybe False (view robotLogUpdated) fr && not sticky of
       False -> pure False
       True -> do
         -- Reset the log updated flag
         zoomGameState clearFocusedRobotLogUpdated
 
-        -- Find and focus an installed "logger" device in the inventory list.
-        let isLogger (InstalledEntry e) = e ^. entityName == "logger"
+        -- Find and focus an equipped "logger" device in the inventory list.
+        let isLogger (EquippedEntry e) = e ^. entityName == "logger"
             isLogger _ = False
             focusLogger = BL.listFindBy isLogger
 
@@ -658,35 +725,89 @@
         oldBotMore <- uiState . uiMoreInfoBot <<.= botMore
         return $ oldTopMore /= topMore || oldBotMore /= botMore
 
-  -- Decide whether we need to update the current goal text, and pop
+  goalOrWinUpdated <- doGoalUpdates
+
+  let redraw = g ^. needsRedraw || inventoryUpdated || replUpdated || logUpdated || infoPanelUpdated || goalOrWinUpdated
+  pure redraw
+
+-- | Either pops up the updated Goals modal
+-- or pops up the Congratulations (Win) modal, or pops
+-- up the Condolences (Lose) modal.
+-- The Win modal will take precendence if the player
+-- has met the necessary conditions to win the game.
+--
+-- If the player chooses to "Keep Playing" from the Win modal, the
+-- updated Goals will then immediately appear.
+-- This is desirable for:
+-- * feedback as to the final goal the player accomplished,
+-- * as a summary of all of the goals of the game
+-- * shows the player more "optional" goals they can continue to pursue
+doGoalUpdates :: EventM Name AppState Bool
+doGoalUpdates = do
+  curGoal <- use (uiState . uiGoal . goalsContent)
+  isCheating <- use (uiState . uiCheatMode)
+  curWinCondition <- use (gameState . winCondition)
+  announcementsSeq <- use (gameState . announcementQueue)
+  let announcementsList = toList announcementsSeq
+
+  -- Decide whether we need to update the current goal text and pop
   -- up a modal dialog.
-  curGoal <- use (uiState . uiGoal)
-  newGoal <-
-    preuse (gameState . winCondition . _WinConditions . _NonEmpty . _1 . objectiveGoal)
+  case curWinCondition of
+    NoWinCondition -> return False
+    WinConditions (Unwinnable False) x -> do
+      -- This clears the "flag" that the Lose dialog needs to pop up
+      gameState . winCondition .= WinConditions (Unwinnable True) x
+      openModal LoseModal
 
-  let goalUpdated = curGoal /= newGoal
-  when goalUpdated $ do
-    uiState . uiGoal .= newGoal
-    case newGoal of
-      Just goal | goal /= [] -> do
-        toggleModal (GoalModal goal)
-      _ -> return ()
+      uiState . uiMenu %= advanceMenu
+      return True
+    WinConditions (Won False) x -> do
+      -- This clears the "flag" that the Win dialog needs to pop up
+      gameState . winCondition .= WinConditions (Won True) x
+      openModal WinModal
 
-  -- Decide whether to show a pop-up modal congratulating the user on
-  -- successfully completing the current challenge.
-  winModalUpdated <- do
-    w <- use (gameState . winCondition)
-    case w of
-      Won False -> do
-        gameState . winCondition .= Won True
-        toggleModal WinModal
-        uiState . uiMenu %= advanceMenu
-        return True
-      _ -> return False
+      uiState . uiMenu %= advanceMenu
+      return True
+    WinConditions _ oc -> do
+      let newGoalTracking = GoalTracking announcementsList $ constructGoalMap isCheating oc
+          -- The "uiGoal" field is intialized with empty members, so we know that
+          -- this will be the first time showing it if it will be nonempty after previously
+          -- being empty.
+          isFirstGoalDisplay = hasAnythingToShow newGoalTracking && not (hasAnythingToShow curGoal)
+          goalWasUpdated = isFirstGoalDisplay || not (null announcementsList)
 
-  let redraw = g ^. needsRedraw || inventoryUpdated || replUpdated || logUpdated || infoPanelUpdated || goalUpdated || winModalUpdated
-  pure redraw
+      -- Decide whether to show a pop-up modal congratulating the user on
+      -- successfully completing the current challenge.
+      when goalWasUpdated $ do
+        let hasMultiple = hasMultipleGoals newGoalTracking
+            defaultFocus =
+              if hasMultiple
+                then ObjectivesList
+                else GoalSummary
 
+            ring =
+              focusRing $
+                map GoalWidgets $
+                  if hasMultiple
+                    then listEnums
+                    else [GoalSummary]
+
+        -- The "uiGoal" field is necessary at least to "persist" the data that is needed
+        -- if the player chooses to later "recall" the goals dialog with CTRL+g.
+        uiState . uiGoal
+          .= GoalDisplay
+            newGoalTracking
+            (GR.makeListWidget newGoalTracking)
+            (focusSetCurrent (GoalWidgets defaultFocus) ring)
+
+        -- This clears the "flag" that indicate that the goals dialog needs to be
+        -- automatically popped up.
+        gameState . announcementQueue .= mempty
+
+        openModal GoalModal
+
+      return goalWasUpdated
+
 -- | Make sure all tiles covering the visible part of the world are
 --   loaded.
 loadVisibleRegion :: EventM Name AppState ()
@@ -719,19 +840,86 @@
 
 -- | Handle a user input event for the REPL.
 handleREPLEvent :: BrickEvent Name AppEvent -> EventM Name AppState ()
-handleREPLEvent = \case
-  ControlKey 'c' -> do
+handleREPLEvent x = do
+  s <- get
+  let repl = s ^. uiState . uiREPL
+      controlMode = repl ^. replControlMode
+      uinput = repl ^. replPromptText
+  case x of
+    MetaChar 'p' ->
+      onlyCreative $ do
+        if T.null uinput
+          then uiState . uiREPL . replControlMode %= cycleEnum
+          else uiState . uiError ?= "Please clear the REPL first."
+    _ -> case controlMode of
+      Typing -> handleREPLEventTyping x
+      Piloting -> handleREPLEventPiloting x
+
+-- | Handle a user "piloting" input event for the REPL.
+handleREPLEventPiloting :: BrickEvent Name AppEvent -> EventM Name AppState ()
+handleREPLEventPiloting x = case x of
+  Key V.KUp -> inputCmd "move"
+  Key V.KDown -> inputCmd "turn back"
+  Key V.KLeft -> inputCmd "turn left"
+  Key V.KRight -> inputCmd "turn right"
+  ShiftKey V.KUp -> inputCmd "turn north"
+  ShiftKey V.KDown -> inputCmd "turn south"
+  ShiftKey V.KLeft -> inputCmd "turn west"
+  ShiftKey V.KRight -> inputCmd "turn east"
+  Key V.KDel -> inputCmd "selfdestruct"
+  CharKey 'g' -> inputCmd "grab"
+  CharKey 'h' -> inputCmd "harvest"
+  CharKey 'd' -> inputCmd "drill forward"
+  CharKey 's' -> inputCmd "scan forward"
+  CharKey 'b' -> inputCmd "blocked"
+  CharKey 'u' -> inputCmd "upload base"
+  _ -> inputCmd "noop"
+ where
+  inputCmd cmdText = do
+    uiState . uiREPL %= setCmd (cmdText <> ";")
+    modify validateREPLForm
+    handleREPLEventTyping $ Key V.KEnter
+
+  setCmd nt repl =
+    repl
+      & replPromptText .~ nt
+      & replPromptType .~ CmdPrompt []
+
+-- | Handle a user input event for the REPL.
+handleREPLEventTyping :: BrickEvent Name AppEvent -> EventM Name AppState ()
+handleREPLEventTyping = \case
+  ControlChar 'c' -> do
     gameState . baseRobot . machine %= cancel
     uiState . uiREPL . replPromptType .= CmdPrompt []
+    uiState . uiREPL . replPromptText .= ""
   Key V.KEnter -> do
     s <- get
     let topCtx = topContext s
         repl = s ^. uiState . uiREPL
         uinput = repl ^. replPromptText
 
-        startBaseProgram t@(ProcessedTerm _ (Module ty _) reqs _) =
-          (gameState . replStatus .~ REPLWorking (Typed Nothing ty reqs))
+        -- The player typed something at the REPL and hit Enter; this
+        -- function takes the resulting ProcessedTerm (if the REPL
+        -- input is valid) and sets up the base robot to run it.
+        startBaseProgram t@(ProcessedTerm (Module tm _) reqs reqCtx) =
+          -- Set the REPL status to Working
+          (gameState . replStatus .~ REPLWorking (Typed Nothing (tm ^. sType) reqs))
+            -- The `reqCtx` maps names of variables defined in the
+            -- term (by `def` statements) to their requirements.
+            -- E.g. if we had `def m = move end`, the reqCtx would
+            -- record the fact that `m` needs the `move` capability.
+            -- We simply add the entire `reqCtx` to the robot's
+            -- context, so we can look up requirements if we later
+            -- need to requirements-check an argument to `build` or
+            -- `reprogram` at runtime.  See the discussion at
+            -- https://github.com/swarm-game/swarm/pull/827 for more
+            -- details.
+            . (gameState . baseRobot . robotContext . defReqs <>~ reqCtx)
+            -- Set up the robot's CESK machine to evaluate/execute the
+            -- given term, being sure to initialize the CESK machine
+            -- environment and store from the top-level context.
             . (gameState . baseRobot . machine .~ initMachine t (topCtx ^. defVals) (topCtx ^. defStore))
+            -- Finally, be sure to activate the base robot.
             . (gameState %~ execState (activateRobot 0))
 
     if not $ s ^. gameState . replWorking
@@ -749,12 +937,12 @@
             Just found
               | T.null uinput -> uiState %= resetREPL "" (CmdPrompt [])
               | otherwise -> do
-                uiState %= resetREPL found (CmdPrompt [])
-                modify validateREPLForm
+                  uiState %= resetREPL found (CmdPrompt [])
+                  modify validateREPLForm
       else continueWithoutRedraw
   Key V.KUp -> modify $ adjReplHistIndex Older
   Key V.KDown -> modify $ adjReplHistIndex Newer
-  ControlKey 'r' -> do
+  ControlChar 'r' -> do
     s <- get
     let uinput = s ^. uiState . uiREPL . replPromptText
     case s ^. uiState . uiREPL . replPromptType of
@@ -773,7 +961,7 @@
       CmdPrompt {} -> continueWithoutRedraw
       SearchPrompt _ ->
         uiState %= resetREPL "" (CmdPrompt [])
-  ControlKey 'd' -> do
+  ControlChar 'd' -> do
     text <- use $ uiState . uiREPL . replPromptText
     if text == T.empty
       then toggleModal QuitModal
@@ -816,12 +1004,12 @@
     -- the CmdPrompt, so when Tab is pressed again, this case then gets executed and
     -- repopulates them.
     | otherwise -> case candidateMatches of
-      [] -> setCmd t []
-      [m] -> setCmd (completeWith m) []
-      -- Perform completion with the first candidate, then populate the list
-      -- of all candidates with the current completion moved to the back
-      -- of the queue.
-      (m : ms) -> setCmd (completeWith m) (ms ++ [m])
+        [] -> setCmd t []
+        [m] -> setCmd (completeWith m) []
+        -- Perform completion with the first candidate, then populate the list
+        -- of all candidates with the current completion moved to the back
+        -- of the queue.
+        (m : ms) -> setCmd (completeWith m) (ms ++ [m])
  where
   -- checks the "parity" of the number of quotes. If odd, then there is an open quote.
   hasOpenQuotes = (== 1) . (`mod` 2) . T.count "\""
@@ -857,17 +1045,17 @@
   case replPrompt of
     CmdPrompt _
       | T.null uinput ->
-        let theType = s ^. gameState . replStatus . replActiveType
-         in s & uiState . uiREPL . replType .~ theType
+          let theType = s ^. gameState . replStatus . replActiveType
+           in s & uiState . uiREPL . replType .~ theType
     CmdPrompt _
       | otherwise ->
-        let result = processTerm' (topCtx ^. defTypes) (topCtx ^. defReqs) uinput
-            theType = case result of
-              Right (Just (ProcessedTerm _ (Module ty _) _ _)) -> Just ty
-              _ -> Nothing
-         in s
-              & uiState . uiREPL . replValid .~ isRight result
-              & uiState . uiREPL . replType .~ theType
+          let result = processTerm' (topCtx ^. defTypes) (topCtx ^. defReqs) uinput
+              theType = case result of
+                Right (Just (ProcessedTerm (Module tm _) _ _)) -> Just (tm ^. sType)
+                _ -> Nothing
+           in s
+                & uiState . uiREPL . replValid .~ isRight result
+                & uiState . uiREPL . replType .~ theType
     SearchPrompt _ -> s
  where
   uinput = s ^. uiState . uiREPL . replPromptText
@@ -902,14 +1090,19 @@
 -- World events
 ------------------------------------------------------------
 
-worldScrollDist :: Int64
+worldScrollDist :: Int32
 worldScrollDist = 8
 
+onlyCreative :: MonadState AppState m => m () -> m ()
+onlyCreative a = do
+  c <- use $ gameState . creativeMode
+  when c a
+
 -- | Handle a user input event in the world view panel.
 handleWorldEvent :: BrickEvent Name AppEvent -> EventM Name AppState ()
 -- scrolling the world view in Creative mode
 handleWorldEvent = \case
-  Key k | k `elem` moveKeys -> onlyCreative $ scrollView (^+^ (worldScrollDist *^ keyToDir k))
+  Key k | k `elem` moveKeys -> onlyCreative $ scrollView (.+^ (worldScrollDist *^ keyToDir k))
   CharKey 'c' -> do
     invalidateCacheEntry WorldCache
     gameState . viewCenterRule .= VCRobot 0
@@ -918,9 +1111,6 @@
   -- Fall-through case: don't do anything.
   _ -> continueWithoutRedraw
  where
-  onlyCreative a = do
-    c <- use $ gameState . creativeMode
-    when c a
   moveKeys =
     [ V.KUp
     , V.KDown
@@ -933,7 +1123,7 @@
     ]
 
 -- | Manually scroll the world view.
-scrollView :: (V2 Int64 -> V2 Int64) -> EventM Name AppState ()
+scrollView :: (Location -> Location) -> EventM Name AppState ()
 scrollView update = do
   -- Manually invalidate the 'WorldCache' instead of just setting
   -- 'needsRedraw'.  I don't quite understand why the latter doesn't
@@ -943,7 +1133,7 @@
   gameState %= modifyViewCenter update
 
 -- | Convert a directional key into a direction.
-keyToDir :: V.Key -> V2 Int64
+keyToDir :: V.Key -> Heading
 keyToDir V.KUp = north
 keyToDir V.KDown = south
 keyToDir V.KRight = east
@@ -952,7 +1142,7 @@
 keyToDir (V.KChar 'j') = south
 keyToDir (V.KChar 'k') = north
 keyToDir (V.KChar 'l') = east
-keyToDir _ = V2 0 0
+keyToDir _ = zero
 
 -- | Adjust the ticks per second speed.
 adjustTPS :: (Int -> Int -> Int) -> AppState -> AppState
@@ -994,17 +1184,15 @@
 makeEntity :: Entity -> EventM Name AppState ()
 makeEntity e = do
   s <- get
-  let mkTy = PolyUnit
-      mkReq = R.singletonCap CMake
-      mkProg = TApp (TConst Make) (TText (e ^. entityName))
-      mkPT = ProcessedTerm mkProg (Module mkTy empty) mkReq empty
+  let name = e ^. entityName
+      mkPT = [tmQ| make $str:name |]
       topStore =
         fromMaybe emptyStore $
           s ^? gameState . baseRobot . robotContext . defStore
 
   case isActive <$> (s ^? gameState . baseRobot) of
     Just False -> do
-      gameState . replStatus .= REPLWorking (Typed Nothing mkTy mkReq)
+      gameState . replStatus .= REPLWorking (Typed Nothing PolyUnit (R.singletonCap CMake))
       gameState . baseRobot . machine .= initMachine mkPT empty topStore
       gameState %= execState (activateRobot 0)
     _ -> continueWithoutRedraw
diff --git a/src/Swarm/TUI/Controller/Util.hs b/src/Swarm/TUI/Controller/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Controller/Util.hs
@@ -0,0 +1,53 @@
+{-# LANGUAGE PatternSynonyms #-}
+
+module Swarm.TUI.Controller.Util where
+
+import Brick hiding (Direction)
+import Brick.Focus
+import Control.Lens
+import Control.Monad (unless)
+import Graphics.Vty qualified as V
+import Swarm.Game.State
+import Swarm.TUI.Model
+import Swarm.TUI.Model.UI
+import Swarm.TUI.View.Util (generateModal)
+
+-- | Pattern synonyms to simplify brick event handler
+pattern Key :: V.Key -> BrickEvent n e
+pattern Key k = VtyEvent (V.EvKey k [])
+
+pattern CharKey, ControlChar, MetaChar :: Char -> BrickEvent n e
+pattern CharKey c = VtyEvent (V.EvKey (V.KChar c) [])
+pattern ControlChar c = VtyEvent (V.EvKey (V.KChar c) [V.MCtrl])
+pattern MetaChar c = VtyEvent (V.EvKey (V.KChar c) [V.MMeta])
+
+pattern ShiftKey :: V.Key -> BrickEvent n e
+pattern ShiftKey k = VtyEvent (V.EvKey k [V.MShift])
+
+pattern EscapeKey :: BrickEvent n e
+pattern EscapeKey = VtyEvent (V.EvKey V.KEsc [])
+
+pattern FKey :: Int -> BrickEvent n e
+pattern FKey c = VtyEvent (V.EvKey (V.KFun c) [])
+
+openModal :: ModalType -> EventM Name AppState ()
+openModal mt = do
+  newModal <- gets $ flip generateModal mt
+  ensurePause
+  uiState . uiModal ?= newModal
+ where
+  -- Set the game to AutoPause if needed
+  ensurePause = do
+    pause <- use $ gameState . paused
+    unless (pause || isRunningModal mt) $ do
+      gameState . runStatus .= AutoPause
+
+-- | The running modals do not autopause the game.
+isRunningModal :: ModalType -> Bool
+isRunningModal = \case
+  RobotsModal -> True
+  MessagesModal -> True
+  _ -> False
+
+setFocus :: FocusablePanel -> EventM Name AppState ()
+setFocus name = uiState . uiFocusRing %= focusSetCurrent (FocusablePanel name)
diff --git a/src/Swarm/TUI/Model.hs b/src/Swarm/TUI/Model.hs
--- a/src/Swarm/TUI/Model.hs
+++ b/src/Swarm/TUI/Model.hs
@@ -16,11 +16,13 @@
   -- * Custom UI label types
   -- $uilabel
   AppEvent (..),
+  FocusablePanel (..),
   Name (..),
 
   -- * Menus and dialogs
   ModalType (..),
-  ButtonSelection (..),
+  Button (..),
+  ButtonAction (..),
   Modal (..),
   modalType,
   modalDialog,
@@ -59,60 +61,21 @@
   InventoryListEntry (..),
   _Separator,
   _InventoryEntry,
-  _InstalledEntry,
-
-  -- ** UI Model
-  UIState,
-  uiMenu,
-  uiPlaying,
-  uiCheatMode,
-  uiFocusRing,
-  uiWorldCursor,
-  uiREPL,
-  uiInventory,
-  uiInventorySort,
-  uiMoreInfoTop,
-  uiMoreInfoBot,
-  uiScrollToEnd,
-  uiError,
-  uiModal,
-  uiGoal,
-  lgTicksPerSecond,
-  lastFrameTime,
-  accumulatedTime,
-  tickCount,
-  frameCount,
-  frameTickCount,
-  lastInfoTime,
-  uiShowFPS,
-  uiShowZero,
-  uiShowRobots,
-  uiHideRobotsUntil,
-  uiInventoryShouldUpdate,
-  uiTPF,
-  uiFPS,
-  scenarioRef,
-  appData,
+  _EquippedEntry,
 
   -- *** REPL Panel Model
   REPLState,
+  ReplControlMode (..),
   replPromptType,
   replPromptEditor,
   replPromptText,
   replValid,
   replLast,
   replType,
+  replControlMode,
   replHistory,
   newREPLEditor,
 
-  -- ** Initialization
-  initFocusRing,
-  defaultPrompt,
-  initREPLState,
-  initLgTicksPerSecond,
-  initUIState,
-  lastEntry,
-
   -- ** Updating
   populateInventoryList,
   infoScroll,
@@ -126,19 +89,18 @@
   logEvent,
 
   -- * App state
-  AppState,
+  AppState (AppState),
   gameState,
   uiState,
   runtimeState,
 
   -- ** Initialization
   AppOpts (..),
-  initAppState,
-  startGame,
-  restartGame,
-  scenarioToAppState,
   Seed,
 
+  -- *** Re-exported types used in options
+  ColorMode (..),
+
   -- ** Utility
   topContext,
   focusedItem,
@@ -148,59 +110,32 @@
 ) where
 
 import Brick
-import Brick.Focus
-import Brick.Widgets.Dialog (Dialog)
-import Brick.Widgets.Edit (Editor, applyEdit, editorText, getEditContents)
 import Brick.Widgets.List qualified as BL
-import Control.Applicative (Applicative (liftA2), (<|>))
 import Control.Lens hiding (from, (<.>))
 import Control.Monad.Except
 import Control.Monad.State
-import Data.Bits (FiniteBits (finiteBitSize))
-import Data.Foldable (toList)
 import Data.List (findIndex)
 import Data.List.NonEmpty (NonEmpty (..))
-import Data.List.NonEmpty qualified as NE
-import Data.Map (Map)
-import Data.Map qualified as M
-import Data.Maybe (fromMaybe, isJust)
-import Data.Sequence (Seq)
-import Data.Sequence qualified as Seq
+import Data.Maybe (fromMaybe)
 import Data.Text (Text)
-import Data.Text qualified as T
-import Data.Text.Zipper qualified as TZ
-import Data.Time (getZonedTime)
 import Data.Vector qualified as V
 import GitHash (GitInfo)
+import Graphics.Vty (ColorMode (..))
 import Linear (zero)
 import Network.Wai.Handler.Warp (Port)
 import Swarm.Game.Entity as E
 import Swarm.Game.Robot
-import Swarm.Game.Scenario (loadScenario)
 import Swarm.Game.ScenarioInfo (
-  ScenarioCollection,
-  ScenarioInfo (..),
   ScenarioInfoPair,
-  ScenarioItem (..),
-  ScenarioStatus (..),
-  normalizeScenarioPath,
-  scMap,
-  scenarioCollectionToList,
-  scenarioItemByPath,
-  scenarioPath,
-  scenarioSolution,
-  scenarioStatus,
   _SISingle,
  )
 import Swarm.Game.State
-import Swarm.Game.World qualified as W
-import Swarm.Language.Types
 import Swarm.TUI.Inventory.Sorting
-import Swarm.Util
+import Swarm.TUI.Model.Menu
+import Swarm.TUI.Model.Name
+import Swarm.TUI.Model.Repl
+import Swarm.TUI.Model.UI
 import Swarm.Version (NewReleaseFailure (NoMainUpstreamRelease))
-import System.Clock
-import System.FilePath (dropTrailingPathSeparator, splitPath, takeFileName)
-import Witch (into)
 
 ------------------------------------------------------------
 -- Custom UI label types
@@ -218,496 +153,12 @@
   | UpstreamVersion (Either NewReleaseFailure String)
   deriving (Show)
 
--- | 'Name' represents names to uniquely identify various components
---   of the UI, such as forms, panels, caches, extents, and lists.
-data Name
-  = -- | The panel containing the REPL.
-    REPLPanel
-  | -- | The panel containing the world view.
-    WorldPanel
-  | -- | The panel showing robot info and inventory on the top left.
-    RobotPanel
-  | -- | The info panel on the bottom left.
-    InfoPanel
-  | -- | The REPL input form.
-    REPLInput
-  | -- | The render cache for the world view.
-    WorldCache
-  | -- | The cached extent for the world view.
-    WorldExtent
-  | -- | The list of inventory items for the currently
-    --   focused robot.
-    InventoryList
-  | -- | The inventory item position in the InventoryList.
-    InventoryListItem Int
-  | -- | The list of main menu choices.
-    MenuList
-  | -- | The list of scenario choices.
-    ScenarioList
-  | -- | The scrollable viewport for the info panel.
-    InfoViewport
-  | -- | The scrollable viewport for any modal dialog.
-    ModalViewport
-  deriving (Eq, Ord, Show, Read)
-
 infoScroll :: ViewportScroll Name
 infoScroll = viewportScroll InfoViewport
 
 modalScroll :: ViewportScroll Name
 modalScroll = viewportScroll ModalViewport
 
-------------------------------------------------------------
--- REPL History
-------------------------------------------------------------
-
--- | An item in the REPL history.
-data REPLHistItem
-  = -- | Something entered by the user.
-    REPLEntry Text
-  | -- | A response printed by the system.
-    REPLOutput Text
-  deriving (Eq, Ord, Show, Read)
-
--- | Useful helper function to only get user input text.
-getREPLEntry :: REPLHistItem -> Maybe Text
-getREPLEntry = \case
-  REPLEntry t -> Just t
-  _ -> Nothing
-
--- | Useful helper function to filter out REPL output.
-isREPLEntry :: REPLHistItem -> Bool
-isREPLEntry = isJust . getREPLEntry
-
--- | Get the text of REPL input/output.
-replItemText :: REPLHistItem -> Text
-replItemText = \case
-  REPLEntry t -> t
-  REPLOutput t -> t
-
--- | History of the REPL with indices (0 is first entry) to the current
---   line and to the first entry since loading saved history.
---   We also (ab)use the length of the REPL as the index of current
---   input line, since that number is one past the index of last entry.
-data REPLHistory = REPLHistory
-  { _replSeq :: Seq REPLHistItem
-  , _replIndex :: Int
-  , _replStart :: Int
-  }
-
-makeLensesWith (lensRules & generateSignatures .~ False) ''REPLHistory
-
--- | Sequence of REPL inputs and outputs, oldest entry is leftmost.
-replSeq :: Lens' REPLHistory (Seq REPLHistItem)
-
--- | The current index in the REPL history (if the user is going back
---   through the history using up/down keys).
-replIndex :: Lens' REPLHistory Int
-
--- | The index of the first entry since loading saved history.
---
--- It will be set on load and reset on save (happens during exit).
-replStart :: Lens' REPLHistory Int
-
--- | Create new REPL history (i.e. from loaded history file lines).
-newREPLHistory :: [REPLHistItem] -> REPLHistory
-newREPLHistory xs =
-  let s = Seq.fromList xs
-   in REPLHistory
-        { _replSeq = s
-        , _replStart = length s
-        , _replIndex = length s
-        }
-
--- | Point the start of REPL history after current last line. See 'replStart'.
-restartREPLHistory :: REPLHistory -> REPLHistory
-restartREPLHistory h = h & replStart .~ replLength h
-
--- | Current number lines of the REPL history - (ab)used as index of input buffer.
-replLength :: REPLHistory -> Int
-replLength = length . _replSeq
-
--- | Add new REPL input - the index must have been pointing one past
---   the last element already, so we increment it to keep it that way.
-addREPLItem :: REPLHistItem -> REPLHistory -> REPLHistory
-addREPLItem t h =
-  h
-    & replSeq %~ (|> t)
-    & replIndex .~ 1 + replLength h
-
--- | Get the latest N items in history, starting with the oldest one.
---
--- This is used to show previous REPL lines in UI, so we need the items
--- sorted in the order they were entered and will be drawn top to bottom.
-getLatestREPLHistoryItems :: Int -> REPLHistory -> [REPLHistItem]
-getLatestREPLHistoryItems n h = toList latestN
- where
-  latestN = Seq.drop oldestIndex $ h ^. replSeq
-  oldestIndex = max (h ^. replStart) $ length (h ^. replSeq) - n
-
-data TimeDir = Newer | Older deriving (Eq, Ord, Show)
-
-moveReplHistIndex :: TimeDir -> Text -> REPLHistory -> REPLHistory
-moveReplHistIndex d lastEntered history = history & replIndex .~ newIndex
- where
-  historyLen = replLength history
-  curText = fromMaybe lastEntered $ getCurrentItemText history
-  curIndex = history ^. replIndex
-  entries = history ^. replSeq
-  -- split repl at index
-  (olderP, newer) = Seq.splitAt curIndex entries
-  -- find first different entry in direction
-  notSameEntry = \case
-    REPLEntry t -> t /= curText
-    _ -> False
-  newIndex = case d of
-    Newer -> maybe historyLen (curIndex +) $ Seq.findIndexL notSameEntry newer
-    Older -> fromMaybe curIndex $ Seq.findIndexR notSameEntry olderP
-
-getCurrentItemText :: REPLHistory -> Maybe Text
-getCurrentItemText history = replItemText <$> Seq.lookup (history ^. replIndex) (history ^. replSeq)
-
-replIndexIsAtInput :: REPLHistory -> Bool
-replIndexIsAtInput repl = repl ^. replIndex == replLength repl
-
--- | Given some text,  removes the REPLEntry within REPLHistory which is equal to that.
---   This is used when the user enters in search mode and want to traverse the history.
---   If a command has been used many times, the history will be populated with it causing
---   the effect that search command always finds the same command.
-removeEntry :: Text -> REPLHistory -> REPLHistory
-removeEntry foundtext hist = hist & replSeq %~ Seq.filter (/= REPLEntry foundtext)
-
--- | Get the last REPLEntry in REPLHistory matching the given text
-lastEntry :: Text -> REPLHistory -> Maybe Text
-lastEntry t h =
-  case Seq.viewr $ Seq.filter matchEntry $ h ^. replSeq of
-    Seq.EmptyR -> Nothing
-    _ Seq.:> a -> Just (replItemText a)
- where
-  matchesText histItem = t `T.isInfixOf` replItemText histItem
-  matchEntry = liftA2 (&&) matchesText isREPLEntry
-
-------------------------------------------------------------
--- REPL
-------------------------------------------------------------
-
--- | This data type tells us how to interpret the text typed
---   by the player at the prompt (which is stored in Editor).
-data REPLPrompt
-  = -- | Interpret the prompt text as a regular command.
-    --   The list is for potential completions, which we can
-    --   cycle through by hitting Tab repeatedly
-    CmdPrompt [Text]
-  | -- | Interpret the prompt text as "search this text in history"
-    SearchPrompt REPLHistory
-
-defaultPrompt :: REPLPrompt
-defaultPrompt = CmdPrompt []
-
-data REPLState = REPLState
-  { _replPromptType :: REPLPrompt
-  , _replPromptEditor :: Editor Text Name
-  , _replValid :: Bool
-  , _replLast :: Text
-  , _replType :: Maybe Polytype
-  , _replHistory :: REPLHistory
-  }
-
-newREPLEditor :: Text -> Editor Text Name
-newREPLEditor t = applyEdit gotoEnd $ editorText REPLInput (Just 1) t
- where
-  ls = T.lines t
-  pos = (length ls - 1, T.length (last ls))
-  gotoEnd = if null ls then id else TZ.moveCursor pos
-
-initREPLState :: REPLHistory -> REPLState
-initREPLState = REPLState defaultPrompt (newREPLEditor "") True "" Nothing
-
-makeLensesWith (lensRules & generateSignatures .~ False) ''REPLState
-
--- | The way we interpret text typed by the player in the REPL prompt.
-replPromptType :: Lens' REPLState REPLPrompt
-
--- | The prompt where the user can type input at the REPL.
-replPromptEditor :: Lens' REPLState (Editor Text Name)
-
--- | Convinience lens to get text from editor and replace it with new
---   one that has the provided text.
-replPromptText :: Lens' REPLState Text
-replPromptText = lens g s
- where
-  g r = r ^. replPromptEditor . to getEditContents . to T.concat
-  s r t = r & replPromptEditor .~ newREPLEditor t
-
--- | Whether the prompt text is a valid 'Term'.
-replValid :: Lens' REPLState Bool
-
--- | The type of the current REPL input which should be displayed to
---   the user (if any).
-replType :: Lens' REPLState (Maybe Polytype)
-
--- | The last thing the user has typed which isn't part of the history.
---   This is used to restore the repl form after the user visited the history.
-replLast :: Lens' REPLState Text
-
--- | History of things the user has typed at the REPL, interleaved
---   with outputs the system has generated.
-replHistory :: Lens' REPLState REPLHistory
-
-------------------------------------------------------------
--- Menus and dialogs
-------------------------------------------------------------
-
-data ModalType
-  = HelpModal
-  | RecipesModal
-  | CommandsModal
-  | MessagesModal
-  | RobotsModal
-  | WinModal
-  | QuitModal
-  | KeepPlayingModal
-  | DescriptionModal Entity
-  | GoalModal [Text]
-  deriving (Eq, Show)
-
-data ButtonSelection = CancelButton | KeepPlayingButton | StartOverButton Seed ScenarioInfoPair | QuitButton | NextButton ScenarioInfoPair
-
-data Modal = Modal
-  { _modalType :: ModalType
-  , _modalDialog :: Dialog ButtonSelection
-  }
-
-makeLenses ''Modal
-
-data MainMenuEntry = NewGame | Tutorial | Messages | About | Quit
-  deriving (Eq, Ord, Show, Read, Bounded, Enum)
-
-data Menu
-  = NoMenu -- We started playing directly from command line, no menu to show
-  | MainMenu (BL.List Name MainMenuEntry)
-  | NewGameMenu (NonEmpty (BL.List Name ScenarioItem)) -- stack of scenario item lists
-  | MessagesMenu
-  | AboutMenu
-
-mainMenu :: MainMenuEntry -> BL.List Name MainMenuEntry
-mainMenu e = BL.list MenuList (V.fromList [minBound .. maxBound]) 1 & BL.listMoveToElement e
-
-makePrisms ''Menu
-
--- | Create a brick 'BL.List' of scenario items from a 'ScenarioCollection'.
-mkScenarioList :: Bool -> ScenarioCollection -> BL.List Name ScenarioItem
-mkScenarioList cheat = flip (BL.list ScenarioList) 1 . V.fromList . filterTest . scenarioCollectionToList
- where
-  filterTest = if cheat then id else filter (\case SICollection n _ -> n /= "Testing"; _ -> True)
-
--- | Given a 'ScenarioCollection' and a 'FilePath' which is the canonical
---   path to some folder or scenario, construct a 'NewGameMenu' stack
---   focused on the given item, if possible.
-mkNewGameMenu :: Bool -> ScenarioCollection -> FilePath -> Maybe Menu
-mkNewGameMenu cheat sc path = NewGameMenu . NE.fromList <$> go (Just sc) (splitPath path) []
- where
-  go :: Maybe ScenarioCollection -> [FilePath] -> [BL.List Name ScenarioItem] -> Maybe [BL.List Name ScenarioItem]
-  go _ [] stk = Just stk
-  go Nothing _ _ = Nothing
-  go (Just curSC) (thing : rest) stk = go nextSC rest (lst : stk)
-   where
-    hasName :: ScenarioItem -> Bool
-    hasName (SISingle (_, ScenarioInfo pth _ _ _)) = takeFileName pth == thing
-    hasName (SICollection nm _) = nm == into @Text (dropTrailingPathSeparator thing)
-
-    lst = BL.listFindBy hasName (mkScenarioList cheat curSC)
-
-    nextSC = case M.lookup (dropTrailingPathSeparator thing) (scMap curSC) of
-      Just (SICollection _ c) -> Just c
-      _ -> Nothing
-
-------------------------------------------------------------
--- Inventory list entries
-------------------------------------------------------------
-
--- | An entry in the inventory list displayed in the info panel.  We
---   can either have an entity with a count in the robot's inventory,
---   an entity installed on the robot, or a labelled separator.  The
---   purpose of the separators is to show a clear distinction between
---   the robot's /inventory/ and its /installed devices/.
-data InventoryListEntry
-  = Separator Text
-  | InventoryEntry Count Entity
-  | InstalledEntry Entity
-  deriving (Eq)
-
-makePrisms ''InventoryListEntry
-
-------------------------------------------------------------
--- UI state
-------------------------------------------------------------
-
--- | The main record holding the UI state.  For access to the fields,
--- see the lenses below.
-data UIState = UIState
-  { _uiMenu :: Menu
-  , _uiPlaying :: Bool
-  , _uiCheatMode :: Bool
-  , _uiFocusRing :: FocusRing Name
-  , _uiWorldCursor :: Maybe W.Coords
-  , _uiREPL :: REPLState
-  , _uiInventory :: Maybe (Int, BL.List Name InventoryListEntry)
-  , _uiInventorySort :: InventorySortOptions
-  , _uiMoreInfoTop :: Bool
-  , _uiMoreInfoBot :: Bool
-  , _uiScrollToEnd :: Bool
-  , _uiError :: Maybe Text
-  , _uiModal :: Maybe Modal
-  , _uiGoal :: Maybe [Text]
-  , _uiShowFPS :: Bool
-  , _uiShowZero :: Bool
-  , _uiHideRobotsUntil :: TimeSpec
-  , _uiInventoryShouldUpdate :: Bool
-  , _uiTPF :: Double
-  , _uiFPS :: Double
-  , _lgTicksPerSecond :: Int
-  , _tickCount :: Int
-  , _frameCount :: Int
-  , _frameTickCount :: Int
-  , _lastFrameTime :: TimeSpec
-  , _accumulatedTime :: TimeSpec
-  , _lastInfoTime :: TimeSpec
-  , _appData :: Map Text Text
-  , _scenarioRef :: Maybe ScenarioInfoPair
-  }
-
---------------------------------------------------
--- Lenses for UIState
-
-let exclude = ['_lgTicksPerSecond]
- in makeLensesWith
-      ( lensRules
-          & generateSignatures .~ False
-          & lensField . mapped . mapped %~ \fn n ->
-            if n `elem` exclude then [] else fn n
-      )
-      ''UIState
-
--- | The current menu state.
-uiMenu :: Lens' UIState Menu
-
--- | Are we currently playing the game?  True = we are playing, and
---   should thus display a world, REPL, etc.; False = we should
---   display the current menu.
-uiPlaying :: Lens' UIState Bool
-
--- | Cheat mode, i.e. are we allowed to turn creative mode on and off?
-uiCheatMode :: Lens' UIState Bool
-
--- | The focus ring is the set of UI panels we can cycle among using
---   the Tab key.
-uiFocusRing :: Lens' UIState (FocusRing Name)
-
--- | The last clicked position on the world view.
-uiWorldCursor :: Lens' UIState (Maybe W.Coords)
-
--- | The state of REPL panel.
-uiREPL :: Lens' UIState REPLState
-
--- | The order and direction of sorting inventory list.
-uiInventorySort :: Lens' UIState InventorySortOptions
-
--- | The hash value of the focused robot entity (so we can tell if its
---   inventory changed) along with a list of the items in the
---   focused robot's inventory.
-uiInventory :: Lens' UIState (Maybe (Int, BL.List Name InventoryListEntry))
-
--- | Does the info panel contain more content past the top of the panel?
-uiMoreInfoTop :: Lens' UIState Bool
-
--- | Does the info panel contain more content past the bottom of the panel?
-uiMoreInfoBot :: Lens' UIState Bool
-
--- | A flag telling the UI to scroll the info panel to the very end
---   (used when a new log message is appended).
-uiScrollToEnd :: Lens' UIState Bool
-
--- | When this is @Just@, it represents a popup box containing an
---   error message that is shown on top of the rest of the UI.
-uiError :: Lens' UIState (Maybe Text)
-
--- | When this is @Just@, it represents a modal to be displayed on
---   top of the UI, e.g. for the Help screen.
-uiModal :: Lens' UIState (Maybe Modal)
-
--- | Status of the scenario goal: whether there is one, and whether it
---   has been displayed to the user initially.
-uiGoal :: Lens' UIState (Maybe [Text])
-
--- | A toggle to show the FPS by pressing `f`
-uiShowFPS :: Lens' UIState Bool
-
--- | A toggle to show or hide inventory items with count 0 by pressing `0`
-uiShowZero :: Lens' UIState Bool
-
--- | Hide robots on the world map.
-uiHideRobotsUntil :: Lens' UIState TimeSpec
-
--- | Whether to show or hide robots on the world map.
-uiShowRobots :: Getter UIState Bool
-uiShowRobots = to (\ui -> ui ^. lastFrameTime > ui ^. uiHideRobotsUntil)
-
--- | Whether the Inventory ui panel should update
-uiInventoryShouldUpdate :: Lens' UIState Bool
-
--- | Computed ticks per milli seconds
-uiTPF :: Lens' UIState Double
-
--- | Computed frames per milli seconds
-uiFPS :: Lens' UIState Double
-
--- | The currently active Scenario description, useful for starting over.
-scenarioRef :: Lens' UIState (Maybe ScenarioInfoPair)
-
--- | The base-2 logarithm of the current game speed in ticks/second.
---   Note that we cap this value to the range of +/- log2 INTMAX.
-lgTicksPerSecond :: Lens' UIState Int
-lgTicksPerSecond = lens _lgTicksPerSecond safeSetLgTicks
- where
-  maxLog = finiteBitSize (maxBound :: Int)
-  maxTicks = maxLog - 2
-  minTicks = 2 - maxLog
-  safeSetLgTicks ui lTicks
-    | lTicks < minTicks = setLgTicks ui minTicks
-    | lTicks > maxTicks = setLgTicks ui maxTicks
-    | otherwise = setLgTicks ui lTicks
-  setLgTicks ui lTicks = ui {_lgTicksPerSecond = lTicks}
-
--- | A counter used to track how many ticks have happened since the
---   last time we updated the ticks/frame statistics.
-tickCount :: Lens' UIState Int
-
--- | A counter used to track how many frames have been rendered since the
---   last time we updated the ticks/frame statistics.
-frameCount :: Lens' UIState Int
-
--- | A counter used to track how many ticks have happened in the
---   current frame, so we can stop when we get to the tick cap.
-frameTickCount :: Lens' UIState Int
-
--- | The time of the last info widget update
-lastInfoTime :: Lens' UIState TimeSpec
-
--- | The time of the last 'Frame' event.
-lastFrameTime :: Lens' UIState TimeSpec
-
--- | The amount of accumulated real time.  Every time we get a 'Frame'
---   event, we accumulate the amount of real time that happened since
---   the last frame, then attempt to take an appropriate number of
---   ticks to "catch up", based on the target tick rate.
---
---   See https://gafferongames.com/post/fix_your_timestep/ .
-accumulatedTime :: Lens' UIState TimeSpec
-
--- | Free-form data loaded from the @data@ directory, for things like
---   the logo, about page, tutorial story, etc.
-appData :: Lens' UIState (Map Text Text)
-
 -- ----------------------------------------------------------------------------
 --                                Runtime state                              --
 -- ----------------------------------------------------------------------------
@@ -792,68 +243,13 @@
 
 -- | Get the currently focused entity from the robot info panel (if
 --   any).  This is just like 'focusedItem' but forgets the
---   distinction between plain inventory items and installed devices.
+--   distinction between plain inventory items and equipped devices.
 focusedEntity :: AppState -> Maybe Entity
 focusedEntity =
   focusedItem >=> \case
     Separator _ -> Nothing
     InventoryEntry _ e -> Just e
-    InstalledEntry e -> Just e
-
---------------------------------------------------
--- UIState initialization
-
--- | The initial state of the focus ring.
-initFocusRing :: FocusRing Name
-initFocusRing = focusRing [REPLPanel, InfoPanel, RobotPanel, WorldPanel]
-
--- | The initial tick speed.
-initLgTicksPerSecond :: Int
-initLgTicksPerSecond = 4 -- 2^4 = 16 ticks / second
-
--- | Initialize the UI state.  This needs to be in the IO monad since
---   it involves reading a REPL history file, getting the current
---   time, and loading text files from the data directory.  The @Bool@
---   parameter indicates whether we should start off by showing the
---   main menu.
-initUIState :: Bool -> Bool -> ExceptT Text IO UIState
-initUIState showMainMenu cheatMode = liftIO $ do
-  historyT <- readFileMayT =<< getSwarmHistoryPath False
-  appDataMap <- readAppData
-  let history = maybe [] (map REPLEntry . T.lines) historyT
-  startTime <- getTime Monotonic
-  return $
-    UIState
-      { _uiMenu = if showMainMenu then MainMenu (mainMenu NewGame) else NoMenu
-      , _uiPlaying = not showMainMenu
-      , _uiCheatMode = cheatMode
-      , _uiFocusRing = initFocusRing
-      , _uiWorldCursor = Nothing
-      , _uiREPL = initREPLState $ newREPLHistory history
-      , _uiInventory = Nothing
-      , _uiInventorySort = defaultSortOptions
-      , _uiMoreInfoTop = False
-      , _uiMoreInfoBot = False
-      , _uiScrollToEnd = False
-      , _uiError = Nothing
-      , _uiModal = Nothing
-      , _uiGoal = Nothing
-      , _uiShowFPS = False
-      , _uiShowZero = True
-      , _uiHideRobotsUntil = startTime - 1
-      , _uiInventoryShouldUpdate = False
-      , _uiTPF = 0
-      , _uiFPS = 0
-      , _lgTicksPerSecond = initLgTicksPerSecond
-      , _lastFrameTime = startTime
-      , _accumulatedTime = 0
-      , _lastInfoTime = 0
-      , _tickCount = 0
-      , _frameCount = 0
-      , _frameTickCount = 0
-      , _appData = appDataMap
-      , _scenarioRef = Nothing
-      }
+    EquippedEntry e -> Just e
 
 ------------------------------------------------------------
 -- Functions for updating the UI state
@@ -868,23 +264,27 @@
   showZero <- use uiShowZero
   sortOptions <- use uiInventorySort
   let mkInvEntry (n, e) = InventoryEntry n e
-      mkInstEntry (_, e) = InstalledEntry e
-      itemList mk label =
+      mkInstEntry (_, e) = EquippedEntry e
+      itemList isInventoryDisplay mk label =
         (\case [] -> []; xs -> Separator label : xs)
           . map mk
           . sortInventory sortOptions
           . filter shouldDisplay
           . elems
-
-      -- Display items if we have a positive number of them, or they
-      -- aren't an installed device.  In other words we don't need to
-      -- display installed devices twice unless we actually have some
-      -- in our inventory in addition to being installed.
-      shouldDisplay (n, e) = n > 0 || showZero && not ((r ^. installedDevices) `E.contains` e)
+       where
+        -- Display items if we have a positive number of them, or they
+        -- aren't an equipped device.  In other words we don't need to
+        -- display equipped devices twice unless we actually have some
+        -- in our inventory in addition to being equipped.
+        shouldDisplay (n, e) =
+          n > 0
+            || isInventoryDisplay
+              && showZero
+              && not ((r ^. equippedDevices) `E.contains` e)
 
       items =
-        (r ^. robotInventory . to (itemList mkInvEntry "Inventory"))
-          ++ (r ^. installedDevices . to (itemList mkInstEntry "Installed devices"))
+        (r ^. robotInventory . to (itemList True mkInvEntry "Inventory"))
+          ++ (r ^. equippedDevices . to (itemList False mkInstEntry "Equipped devices"))
 
       -- Attempt to keep the selected element steady.
       sel = mList >>= BL.listSelectedElement -- Get the currently selected element+index.
@@ -896,8 +296,8 @@
         -- if it's not there, keep the index the same.
         Just (selIdx, InventoryEntry _ e) ->
           fromMaybe selIdx (findIndex ((== Just e) . preview (_InventoryEntry . _2)) items)
-        Just (selIdx, InstalledEntry e) ->
-          fromMaybe selIdx (findIndex ((== Just e) . preview _InstalledEntry) items)
+        Just (selIdx, EquippedEntry e) ->
+          fromMaybe selIdx (findIndex ((== Just e) . preview _EquippedEntry) items)
         Just (selIdx, _) -> selIdx
 
       -- Create the new list, focused at the desired index.
@@ -913,72 +313,24 @@
 
 -- | Command-line options for configuring the app.
 data AppOpts = AppOpts
-  { -- | Explicit seed chosen by the user.
-    userSeed :: Maybe Seed
-  , -- | Scenario the user wants to play.
-    userScenario :: Maybe FilePath
-  , -- | Code to be run on base.
-    scriptToRun :: Maybe FilePath
-  , -- | Automatically run the solution defined in the scenario file
-    autoPlay :: Bool
-  , -- | Should cheat mode be enabled?
-    cheatMode :: Bool
-  , -- | Explicit port on which to run the web API
-    userWebPort :: Maybe Port
-  , -- | Information about the Git repository (not present in release).
-    repoGitInfo :: Maybe GitInfo
+  { userSeed :: Maybe Seed
+  -- ^ Explicit seed chosen by the user.
+  , userScenario :: Maybe FilePath
+  -- ^ Scenario the user wants to play.
+  , scriptToRun :: Maybe FilePath
+  -- ^ Code to be run on base.
+  , autoPlay :: Bool
+  -- ^ Automatically run the solution defined in the scenario file
+  , cheatMode :: Bool
+  -- ^ Should cheat mode be enabled?
+  , colorMode :: Maybe ColorMode
+  -- ^ What colour mode should be used?
+  , userWebPort :: Maybe Port
+  -- ^ Explicit port on which to run the web API
+  , repoGitInfo :: Maybe GitInfo
+  -- ^ Information about the Git repository (not present in release).
   }
 
--- | Initialize the 'AppState'.
-initAppState :: AppOpts -> ExceptT Text IO AppState
-initAppState AppOpts {..} = do
-  let isRunningInitialProgram = isJust scriptToRun || autoPlay
-      skipMenu = isJust userScenario || isRunningInitialProgram || isJust userSeed
-  gs <- initGameState
-  ui <- initUIState (not skipMenu) cheatMode
-  let rs = initRuntimeState
-  case skipMenu of
-    False -> return $ AppState gs ui rs
-    True -> do
-      (scenario, path) <- loadScenario (fromMaybe "classic" userScenario) (gs ^. entityMap)
-
-      let maybeAutoplay = do
-            guard autoPlay
-            soln <- scenario ^. scenarioSolution
-            return $ SuggestedSolution soln
-      let realToRun = maybeAutoplay <|> (ScriptPath <$> scriptToRun)
-
-      execStateT
-        (startGameWithSeed userSeed (scenario, ScenarioInfo path NotStarted NotStarted NotStarted) realToRun)
-        (AppState gs ui rs)
-
--- | Load a 'Scenario' and start playing the game.
-startGame :: (MonadIO m, MonadState AppState m) => ScenarioInfoPair -> Maybe CodeToRun -> m ()
-startGame = startGameWithSeed Nothing
-
--- | Re-initialize the game from the stored reference to the current scenario.
---
--- Note that "restarting" is intended only for "scenarios";
--- with some scenarios, it may be possible to get stuck so that it is
--- either impossible or very annoying to win, so being offered an
--- option to restart is more user-friendly.
---
--- Since scenarios are stored as a Maybe in the UI state, we handle the Nothing
--- case upstream so that the Scenario passed to this function definitely exists.
-restartGame :: (MonadIO m, MonadState AppState m) => Seed -> ScenarioInfoPair -> m ()
-restartGame currentSeed siPair = startGameWithSeed (Just currentSeed) siPair Nothing
-
--- | Load a 'Scenario' and start playing the game, with the
---   possibility for the user to override the seed.
-startGameWithSeed :: (MonadIO m, MonadState AppState m) => Maybe Seed -> ScenarioInfoPair -> Maybe CodeToRun -> m ()
-startGameWithSeed userSeed siPair@(_scene, si) toRun = do
-  t <- liftIO getZonedTime
-  ss <- use $ gameState . scenarios
-  p <- liftIO $ normalizeScenarioPath ss (si ^. scenarioPath)
-  gameState . currentScenarioPath .= Just p
-  gameState . scenarios . scenarioItemByPath p . _SISingle . _2 . scenarioStatus .= InProgress t 0 0
-  scenarioToAppState siPair userSeed toRun
-
 -- | Extract the scenario which would come next in the menu from the
 --   currently selected scenario (if any).  Can return @Nothing@ if
 --   either we are not in the @NewGameMenu@, or the current scenario
@@ -1005,34 +357,3 @@
   ctxPossiblyWithIt = case s ^. gameState . replStatus of
     REPLDone (Just p) -> ctx & at "it" ?~ p
     _ -> ctx
-
--- XXX do we need to keep an old entity map around???
-
--- | Modify the 'AppState' appropriately when starting a new scenario.
-scenarioToAppState :: (MonadIO m, MonadState AppState m) => ScenarioInfoPair -> Maybe Seed -> Maybe CodeToRun -> m ()
-scenarioToAppState siPair@(scene, _) userSeed toRun = do
-  withLensIO gameState $ scenarioToGameState scene userSeed toRun
-  withLensIO uiState $ scenarioToUIState siPair
- where
-  withLensIO :: (MonadIO m, MonadState AppState m) => Lens' AppState x -> (x -> IO x) -> m ()
-  withLensIO l a = do
-    x <- use l
-    x' <- liftIO $ a x
-    l .= x'
-
--- | Modify the UI state appropriately when starting a new scenario.
-scenarioToUIState :: ScenarioInfoPair -> UIState -> IO UIState
-scenarioToUIState siPair u =
-  return $
-    u
-      & uiPlaying .~ True
-      & uiGoal .~ Nothing
-      & uiFocusRing .~ initFocusRing
-      & uiInventory .~ Nothing
-      & uiInventorySort .~ defaultSortOptions
-      & uiShowFPS .~ False
-      & uiShowZero .~ True
-      & lgTicksPerSecond .~ initLgTicksPerSecond
-      & uiREPL .~ initREPLState (u ^. uiREPL . replHistory)
-      & uiREPL . replHistory %~ restartREPLHistory
-      & scenarioRef ?~ siPair
diff --git a/src/Swarm/TUI/Model/Achievement/Attainment.hs b/src/Swarm/TUI/Model/Achievement/Attainment.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Model/Achievement/Attainment.hs
@@ -0,0 +1,47 @@
+{-# LANGUAGE TemplateHaskell #-}
+
+module Swarm.TUI.Model.Achievement.Attainment where
+
+import Control.Lens hiding (from, (<.>))
+import Data.Aeson (
+  Options (..),
+  defaultOptions,
+  genericParseJSON,
+  genericToJSON,
+ )
+import Data.Function (on)
+import Data.Time (ZonedTime, zonedTimeToUTC)
+import Data.Yaml as Y
+import GHC.Generics (Generic)
+import Swarm.TUI.Model.Achievement.Definitions
+
+data Attainment = Attainment
+  { _achievement :: CategorizedAchievement
+  , _maybeScenarioPath :: Maybe FilePath
+  -- ^ from which scenario was it obtained?
+  , _obtainedAt :: ZonedTime
+  }
+  deriving (Generic)
+
+makeLenses ''Attainment
+
+instance Eq Attainment where
+  (==) = (==) `on` _achievement
+
+instance Ord Attainment where
+  compare = compare `on` (zonedTimeToUTC . _obtainedAt)
+
+instance Semigroup Attainment where
+  (<>) = min
+
+instance FromJSON Attainment where
+  parseJSON = genericParseJSON achievementJsonOptions
+
+instance ToJSON Attainment where
+  toJSON = genericToJSON achievementJsonOptions
+
+achievementJsonOptions :: Options
+achievementJsonOptions =
+  defaultOptions
+    { fieldLabelModifier = tail -- drops leading underscore
+    }
diff --git a/src/Swarm/TUI/Model/Achievement/Definitions.hs b/src/Swarm/TUI/Model/Achievement/Definitions.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Model/Achievement/Definitions.hs
@@ -0,0 +1,89 @@
+module Swarm.TUI.Model.Achievement.Definitions where
+
+import Data.Aeson
+import Data.Text (Text)
+import GHC.Generics (Generic)
+import Swarm.Util
+
+data ExpectedEffort
+  = Trivial
+  | Easy
+  | Moderate
+  | Gruelling
+  deriving (Eq, Ord, Show, Bounded, Enum, Generic, FromJSON, ToJSON)
+
+data Quotation = Quotation
+  { attribution :: Text
+  , content :: Text
+  }
+  deriving (Eq, Show, Generic, FromJSON, ToJSON)
+
+data FlavorText
+  = Freeform Text
+  | FTQuotation Quotation
+  deriving (Eq, Show, Generic, FromJSON, ToJSON)
+
+data AchievementInfo = AchievementInfo
+  { title :: Text
+  -- ^ Guidelines:
+  -- * prefer puns, pop culture references, etc.
+  -- * should be a phrase in Title Case.
+  -- * For achievements that are "obfuscated", this can be
+  --   a vague "clue" as to what the attainment entails.
+  , humorousElaboration :: Maybe FlavorText
+  -- ^ Explain the reference, e.g. in the form of a full quote
+  -- from a movie, or something you might find
+  -- in a fortune cookie
+  , attainmentProcess :: Text
+  -- ^ Precisely what must be done to obtain this achievement.
+  , effort :: ExpectedEffort
+  , isObfuscated :: Bool
+  -- ^ Hides the attainment process until after the achievement is attained.
+  -- Best when the title + elaboration constitute a good clue.
+  }
+  deriving (Eq, Show, Generic, FromJSON, ToJSON)
+
+data CategorizedAchievement
+  = GlobalAchievement GlobalAchievement
+  | GameplayAchievement GameplayAchievement
+  deriving (Eq, Ord, Show, Generic)
+
+categorizedAchievementJsonOptions :: Options
+categorizedAchievementJsonOptions =
+  defaultOptions
+    { sumEncoding = UntaggedValue
+    }
+
+instance ToJSON CategorizedAchievement where
+  toJSON = genericToJSON categorizedAchievementJsonOptions
+
+instance FromJSON CategorizedAchievement where
+  parseJSON = genericParseJSON categorizedAchievementJsonOptions
+
+-- | Achievements that entail some aggregate of actions
+-- across scenarios
+data GlobalAchievement
+  = CompletedSingleTutorial
+  | CompletedAllTutorials
+  | LookedAtAboutScreen
+  deriving (Eq, Ord, Show, Bounded, Enum, Generic)
+
+instance FromJSON GlobalAchievement
+instance ToJSON GlobalAchievement
+
+-- | Achievements obtained while playing a single scenario
+data GameplayAchievement
+  = CraftedBitcoin
+  | RobotIntoWater
+  | AttemptSelfDestructBase
+  | DestroyedBase
+  | LoseScenario
+  deriving (Eq, Ord, Show, Bounded, Enum, Generic)
+
+instance FromJSON GameplayAchievement
+instance ToJSON GameplayAchievement
+
+listAchievements :: [CategorizedAchievement]
+listAchievements =
+  map GlobalAchievement listEnums
+    <> map GameplayAchievement listEnums
diff --git a/src/Swarm/TUI/Model/Achievement/Description.hs b/src/Swarm/TUI/Model/Achievement/Description.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Model/Achievement/Description.hs
@@ -0,0 +1,70 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Swarm.TUI.Model.Achievement.Description where
+
+import Swarm.TUI.Model.Achievement.Definitions
+
+describe :: CategorizedAchievement -> AchievementInfo
+describe (GlobalAchievement CompletedSingleTutorial) =
+  AchievementInfo
+    "Welcome Freshmen"
+    (Just $ Freeform "School is in session!")
+    "Complete one of the tutorials."
+    Easy
+    False
+describe (GlobalAchievement CompletedAllTutorials) =
+  AchievementInfo
+    "Autodidact"
+    ( Just $
+        FTQuotation $
+          Quotation
+            "Terry Pratchet"
+            "I didn't go to university... But I have sympathy for those who did."
+    )
+    "Complete all of the tutorials."
+    Moderate
+    False
+describe (GlobalAchievement LookedAtAboutScreen) =
+  AchievementInfo
+    "About time!"
+    Nothing
+    "View the About screen."
+    Trivial
+    True
+describe (GameplayAchievement CraftedBitcoin) =
+  -- Bitcoin is the deepest level of the recipes
+  -- hierarchy.
+  AchievementInfo
+    "Master of Your Craft"
+    Nothing
+    "Make a Bitcoin"
+    Moderate
+    True
+describe (GameplayAchievement RobotIntoWater) =
+  AchievementInfo
+    "Watery Grave"
+    (Just $ Freeform "This little robot thinks he's a submarine.")
+    "Destroy a robot by sending it into the water."
+    Easy
+    True
+describe (GameplayAchievement AttemptSelfDestructBase) =
+  AchievementInfo
+    "Call of the Void"
+    (Just $ Freeform "What does that big red button do?")
+    "Attempt to self-destruct your base."
+    Easy
+    True
+describe (GameplayAchievement DestroyedBase) =
+  AchievementInfo
+    "That Could Have Gone Better"
+    (Just $ Freeform "Boom.")
+    "Actually destroy your base."
+    Moderate
+    True
+describe (GameplayAchievement LoseScenario) =
+  AchievementInfo
+    "Silver Lining"
+    (Just $ Freeform "Here's your consolation prize.")
+    "Lose at a scenario."
+    Easy
+    True
diff --git a/src/Swarm/TUI/Model/Achievement/Persistence.hs b/src/Swarm/TUI/Model/Achievement/Persistence.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Model/Achievement/Persistence.hs
@@ -0,0 +1,58 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Swarm.TUI.Model.Achievement.Persistence where
+
+import Control.Arrow (left)
+import Control.Carrier.Lift (sendIO)
+import Control.Monad (forM, forM_)
+import Data.Either (partitionEithers)
+import Data.Yaml qualified as Y
+import Swarm.TUI.Model.Achievement.Attainment
+import Swarm.TUI.Model.Achievement.Definitions
+import Swarm.TUI.Model.Failure
+import Swarm.Util
+import System.Directory (
+  doesDirectoryExist,
+  doesFileExist,
+  listDirectory,
+ )
+import System.FilePath ((</>))
+
+-- | Get path to swarm achievements, optionally creating necessary
+--   directories.
+getSwarmAchievementsPath :: Bool -> IO FilePath
+getSwarmAchievementsPath createDirs = getSwarmXdgDataSubdir createDirs "achievement"
+
+-- | Load saved info about achievements from XDG data directory.
+-- Returns a tuple of warnings and attained achievements.
+loadAchievementsInfo ::
+  IO ([SystemFailure], [Attainment])
+loadAchievementsInfo = do
+  savedAchievementsPath <- getSwarmAchievementsPath False
+  doesParentExist <- doesDirectoryExist savedAchievementsPath
+  if doesParentExist
+    then do
+      contents <- listDirectory savedAchievementsPath
+      eithersList <- forM contents $ \p -> do
+        let fullPath = savedAchievementsPath </> p
+        isFile <- doesFileExist fullPath
+        if isFile
+          then do
+            eitherDecodedFile <- sendIO (Y.decodeFileEither fullPath)
+            return $ left (AssetNotLoaded Achievement p . CanNotParse) eitherDecodedFile
+          else return . Left $ AssetNotLoaded Achievement p (EntryNot File)
+      return $ partitionEithers eithersList
+    else return ([AssetNotLoaded Achievement "." (DoesNotExist Directory)], [])
+
+-- | Save info about achievements to XDG data directory.
+saveAchievementsInfo ::
+  [Attainment] ->
+  IO ()
+saveAchievementsInfo attainmentList = do
+  savedAchievementsPath <- getSwarmAchievementsPath True
+  forM_ attainmentList $ \x -> do
+    let achievementName = case _achievement x of
+          GlobalAchievement y -> show y
+          GameplayAchievement y -> show y
+        fullPath = savedAchievementsPath </> achievementName
+    Y.encodeFile fullPath x
diff --git a/src/Swarm/TUI/Model/Failure.hs b/src/Swarm/TUI/Model/Failure.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Model/Failure.hs
@@ -0,0 +1,51 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- |
+-- Module      :  Swarm.Game.Failure
+-- Copyright   :  Ondřej Šebek
+-- Maintainer  :  ondras98@icloud.com
+--
+-- SPDX-License-Identifier: BSD-3-Clause
+--
+-- A data type to represent system failures.
+--
+-- These failures are often not fatal and serve
+-- to create common infrastructure for logging.
+module Swarm.TUI.Model.Failure where
+
+import Data.Char (toLower)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Yaml (ParseException, prettyPrintParseException)
+
+data SystemFailure
+  = AssetNotLoaded Asset FilePath LoadingFailure
+
+data Asset = Achievement | Data | History | Save
+  deriving (Eq, Show)
+
+data Entry = Directory | File
+  deriving (Eq, Show)
+
+data LoadingFailure
+  = DoesNotExist Entry
+  | EntryNot Entry
+  | CanNotParse ParseException
+
+tShowLow :: Show a => a -> Text
+tShowLow = T.pack . map toLower . show
+
+tShow :: Show a => a -> Text
+tShow = T.pack . show
+
+prettyLoadingFailure :: LoadingFailure -> Text
+prettyLoadingFailure = \case
+  DoesNotExist e -> "The " <> tShowLow e <> " is missing!"
+  EntryNot e -> "The entry is not a " <> tShowLow e <> "!"
+  CanNotParse p -> "Parse failure:\n" <> T.pack (indent 8 $ prettyPrintParseException p)
+ where
+  indent n = unlines . map (replicate n ' ' ++) . lines
+
+prettyFailure :: SystemFailure -> Text
+prettyFailure = \case
+  AssetNotLoaded a fp l -> T.unwords ["Failed to acquire", tShowLow a, tShow fp] <> ": " <> prettyLoadingFailure l
diff --git a/src/Swarm/TUI/Model/Menu.hs b/src/Swarm/TUI/Model/Menu.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Model/Menu.hs
@@ -0,0 +1,129 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE ViewPatterns #-}
+
+module Swarm.TUI.Model.Menu where
+
+import Brick.Widgets.Dialog (Dialog)
+import Brick.Widgets.List qualified as BL
+import Control.Lens hiding (from, (<.>))
+import Data.List.NonEmpty (NonEmpty (..))
+import Data.List.NonEmpty qualified as NE
+import Data.Map qualified as M
+import Data.Text (Text)
+import Data.Vector qualified as V
+import Swarm.Game.Entity as E
+import Swarm.Game.ScenarioInfo (
+  ScenarioCollection,
+  ScenarioInfo (..),
+  ScenarioInfoPair,
+  ScenarioItem (..),
+  scMap,
+  scenarioCollectionToList,
+ )
+import Swarm.Game.State
+import Swarm.TUI.Model.Achievement.Definitions
+import Swarm.TUI.Model.Name
+import Swarm.Util
+import System.FilePath (dropTrailingPathSeparator, splitPath, takeFileName)
+import Witch (into)
+
+------------------------------------------------------------
+-- Menus and dialogs
+------------------------------------------------------------
+
+data ModalType
+  = HelpModal
+  | RecipesModal
+  | CommandsModal
+  | MessagesModal
+  | RobotsModal
+  | WinModal
+  | LoseModal
+  | QuitModal
+  | KeepPlayingModal
+  | DescriptionModal Entity
+  | GoalModal
+  deriving (Show)
+
+data ButtonAction
+  = Cancel
+  | KeepPlaying
+  | StartOver Seed ScenarioInfoPair
+  | QuitAction
+  | Next ScenarioInfoPair
+
+data Modal = Modal
+  { _modalType :: ModalType
+  , _modalDialog :: Dialog ButtonAction Name
+  }
+
+makeLenses ''Modal
+
+data MainMenuEntry
+  = NewGame
+  | Tutorial
+  | Achievements
+  | Messages
+  | About
+  | Quit
+  deriving (Eq, Ord, Show, Read, Bounded, Enum)
+
+data Menu
+  = NoMenu -- We started playing directly from command line, no menu to show
+  | MainMenu (BL.List Name MainMenuEntry)
+  | NewGameMenu (NonEmpty (BL.List Name ScenarioItem)) -- stack of scenario item lists
+  | AchievementsMenu (BL.List Name CategorizedAchievement)
+  | MessagesMenu
+  | AboutMenu
+
+mainMenu :: MainMenuEntry -> BL.List Name MainMenuEntry
+mainMenu e = BL.list MenuList (V.fromList listEnums) 1 & BL.listMoveToElement e
+
+makePrisms ''Menu
+
+-- | Create a brick 'BL.List' of scenario items from a 'ScenarioCollection'.
+mkScenarioList :: Bool -> ScenarioCollection -> BL.List Name ScenarioItem
+mkScenarioList cheat = flip (BL.list ScenarioList) 1 . V.fromList . filterTest . scenarioCollectionToList
+ where
+  filterTest = if cheat then id else filter (\case SICollection n _ -> n /= "Testing"; _ -> True)
+
+-- | Given a 'ScenarioCollection' and a 'FilePath' which is the canonical
+--   path to some folder or scenario, construct a 'NewGameMenu' stack
+--   focused on the given item, if possible.
+mkNewGameMenu :: Bool -> ScenarioCollection -> FilePath -> Maybe Menu
+mkNewGameMenu cheat sc path = NewGameMenu . NE.fromList <$> go (Just sc) (splitPath path) []
+ where
+  go :: Maybe ScenarioCollection -> [FilePath] -> [BL.List Name ScenarioItem] -> Maybe [BL.List Name ScenarioItem]
+  go _ [] stk = Just stk
+  go Nothing _ _ = Nothing
+  go (Just curSC) (thing : rest) stk = go nextSC rest (lst : stk)
+   where
+    hasName :: ScenarioItem -> Bool
+    hasName (SISingle (_, ScenarioInfo pth _ _ _)) = takeFileName pth == thing
+    hasName (SICollection nm _) = nm == into @Text (dropTrailingPathSeparator thing)
+
+    lst = BL.listFindBy hasName (mkScenarioList cheat curSC)
+
+    nextSC = case M.lookup (dropTrailingPathSeparator thing) (scMap curSC) of
+      Just (SICollection _ c) -> Just c
+      _ -> Nothing
+
+------------------------------------------------------------
+-- Inventory list entries
+------------------------------------------------------------
+
+-- | An entry in the inventory list displayed in the info panel.  We
+--   can either have an entity with a count in the robot's inventory,
+--   an entity equipped on the robot, or a labelled separator.  The
+--   purpose of the separators is to show a clear distinction between
+--   the robot's /inventory/ and its /equipped devices/.
+data InventoryListEntry
+  = Separator Text
+  | InventoryEntry Count Entity
+  | EquippedEntry Entity
+  deriving (Eq)
+
+makePrisms ''InventoryListEntry
diff --git a/src/Swarm/TUI/Model/Name.hs b/src/Swarm/TUI/Model/Name.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Model/Name.hs
@@ -0,0 +1,57 @@
+module Swarm.TUI.Model.Name where
+
+data FocusablePanel
+  = -- | The panel containing the REPL.
+    REPLPanel
+  | -- | The panel containing the world view.
+    WorldPanel
+  | -- | The panel showing robot info and inventory on the top left.
+    RobotPanel
+  | -- | The info panel on the bottom left.
+    InfoPanel
+  deriving (Eq, Ord, Show, Read, Bounded, Enum)
+
+data GoalWidget
+  = ObjectivesList
+  | GoalSummary
+  deriving (Eq, Ord, Show, Read, Bounded, Enum)
+
+-- | Clickable buttons in modal dialogs.
+data Button
+  = CancelButton
+  | KeepPlayingButton
+  | StartOverButton
+  | QuitButton
+  | NextButton
+  deriving (Eq, Ord, Show, Read, Bounded, Enum)
+
+-- | 'Name' represents names to uniquely identify various components
+--   of the UI, such as forms, panels, caches, extents, lists, and buttons.
+data Name
+  = FocusablePanel FocusablePanel
+  | -- | The REPL input form.
+    REPLInput
+  | -- | The render cache for the world view.
+    WorldCache
+  | -- | The cached extent for the world view.
+    WorldExtent
+  | -- | The list of inventory items for the currently
+    --   focused robot.
+    InventoryList
+  | -- | The inventory item position in the InventoryList.
+    InventoryListItem Int
+  | -- | The list of main menu choices.
+    MenuList
+  | -- | The list of achievements.
+    AchievementList
+  | -- | The list of goals/objectives.
+    GoalWidgets GoalWidget
+  | -- | The list of scenario choices.
+    ScenarioList
+  | -- | The scrollable viewport for the info panel.
+    InfoViewport
+  | -- | The scrollable viewport for any modal dialog.
+    ModalViewport
+  | -- | A clickable button in a modal dialog.
+    Button Button
+  deriving (Eq, Ord, Show, Read)
diff --git a/src/Swarm/TUI/Model/Repl.hs b/src/Swarm/TUI/Model/Repl.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Model/Repl.hs
@@ -0,0 +1,266 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE ViewPatterns #-}
+
+module Swarm.TUI.Model.Repl (
+  -- ** REPL
+  REPLHistItem (..),
+  replItemText,
+  isREPLEntry,
+  getREPLEntry,
+  REPLHistory,
+  replIndex,
+  replLength,
+  replSeq,
+  newREPLHistory,
+  addREPLItem,
+  restartREPLHistory,
+  getLatestREPLHistoryItems,
+  moveReplHistIndex,
+  getCurrentItemText,
+  replIndexIsAtInput,
+  TimeDir (..),
+
+  -- ** Prompt utils
+  REPLPrompt (..),
+  removeEntry,
+
+  -- *** REPL Panel Model
+  REPLState,
+  ReplControlMode (..),
+  replPromptType,
+  replPromptEditor,
+  replPromptText,
+  replValid,
+  replLast,
+  replType,
+  replControlMode,
+  replHistory,
+  newREPLEditor,
+
+  -- ** Initialization
+  initREPLState,
+  defaultPrompt,
+  lastEntry,
+) where
+
+import Brick.Widgets.Edit (Editor, applyEdit, editorText, getEditContents)
+import Control.Applicative (Applicative (liftA2))
+import Control.Lens hiding (from, (<.>))
+import Data.Foldable (toList)
+import Data.Maybe (fromMaybe, isJust)
+import Data.Sequence (Seq)
+import Data.Sequence qualified as Seq
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.Zipper qualified as TZ
+import Swarm.Language.Types
+import Swarm.TUI.Model.Name
+
+------------------------------------------------------------
+-- REPL History
+------------------------------------------------------------
+
+-- | An item in the REPL history.
+data REPLHistItem
+  = -- | Something entered by the user.
+    REPLEntry Text
+  | -- | A response printed by the system.
+    REPLOutput Text
+  deriving (Eq, Ord, Show, Read)
+
+-- | Useful helper function to only get user input text.
+getREPLEntry :: REPLHistItem -> Maybe Text
+getREPLEntry = \case
+  REPLEntry t -> Just t
+  _ -> Nothing
+
+-- | Useful helper function to filter out REPL output.
+isREPLEntry :: REPLHistItem -> Bool
+isREPLEntry = isJust . getREPLEntry
+
+-- | Get the text of REPL input/output.
+replItemText :: REPLHistItem -> Text
+replItemText = \case
+  REPLEntry t -> t
+  REPLOutput t -> t
+
+-- | History of the REPL with indices (0 is first entry) to the current
+--   line and to the first entry since loading saved history.
+--   We also (ab)use the length of the REPL as the index of current
+--   input line, since that number is one past the index of last entry.
+data REPLHistory = REPLHistory
+  { _replSeq :: Seq REPLHistItem
+  , _replIndex :: Int
+  , _replStart :: Int
+  }
+
+makeLensesWith (lensRules & generateSignatures .~ False) ''REPLHistory
+
+-- | Sequence of REPL inputs and outputs, oldest entry is leftmost.
+replSeq :: Lens' REPLHistory (Seq REPLHistItem)
+
+-- | The current index in the REPL history (if the user is going back
+--   through the history using up/down keys).
+replIndex :: Lens' REPLHistory Int
+
+-- | The index of the first entry since loading saved history.
+--
+-- It will be set on load and reset on save (happens during exit).
+replStart :: Lens' REPLHistory Int
+
+-- | Create new REPL history (i.e. from loaded history file lines).
+newREPLHistory :: [REPLHistItem] -> REPLHistory
+newREPLHistory xs =
+  let s = Seq.fromList xs
+   in REPLHistory
+        { _replSeq = s
+        , _replStart = length s
+        , _replIndex = length s
+        }
+
+-- | Point the start of REPL history after current last line. See 'replStart'.
+restartREPLHistory :: REPLHistory -> REPLHistory
+restartREPLHistory h = h & replStart .~ replLength h
+
+-- | Current number lines of the REPL history - (ab)used as index of input buffer.
+replLength :: REPLHistory -> Int
+replLength = length . _replSeq
+
+-- | Add new REPL input - the index must have been pointing one past
+--   the last element already, so we increment it to keep it that way.
+addREPLItem :: REPLHistItem -> REPLHistory -> REPLHistory
+addREPLItem t h =
+  h
+    & replSeq %~ (|> t)
+    & replIndex .~ 1 + replLength h
+
+-- | Get the latest N items in history, starting with the oldest one.
+--
+-- This is used to show previous REPL lines in UI, so we need the items
+-- sorted in the order they were entered and will be drawn top to bottom.
+getLatestREPLHistoryItems :: Int -> REPLHistory -> [REPLHistItem]
+getLatestREPLHistoryItems n h = toList latestN
+ where
+  latestN = Seq.drop oldestIndex $ h ^. replSeq
+  oldestIndex = max (h ^. replStart) $ length (h ^. replSeq) - n
+
+data TimeDir = Newer | Older deriving (Eq, Ord, Show)
+
+moveReplHistIndex :: TimeDir -> Text -> REPLHistory -> REPLHistory
+moveReplHistIndex d lastEntered history = history & replIndex .~ newIndex
+ where
+  historyLen = replLength history
+  curText = fromMaybe lastEntered $ getCurrentItemText history
+  curIndex = history ^. replIndex
+  entries = history ^. replSeq
+  -- split repl at index
+  (olderP, newer) = Seq.splitAt curIndex entries
+  -- find first different entry in direction
+  notSameEntry = \case
+    REPLEntry t -> t /= curText
+    _ -> False
+  newIndex = case d of
+    Newer -> maybe historyLen (curIndex +) $ Seq.findIndexL notSameEntry newer
+    Older -> fromMaybe curIndex $ Seq.findIndexR notSameEntry olderP
+
+getCurrentItemText :: REPLHistory -> Maybe Text
+getCurrentItemText history = replItemText <$> Seq.lookup (history ^. replIndex) (history ^. replSeq)
+
+replIndexIsAtInput :: REPLHistory -> Bool
+replIndexIsAtInput repl = repl ^. replIndex == replLength repl
+
+-- | Given some text,  removes the REPLEntry within REPLHistory which is equal to that.
+--   This is used when the user enters in search mode and want to traverse the history.
+--   If a command has been used many times, the history will be populated with it causing
+--   the effect that search command always finds the same command.
+removeEntry :: Text -> REPLHistory -> REPLHistory
+removeEntry foundtext hist = hist & replSeq %~ Seq.filter (/= REPLEntry foundtext)
+
+-- | Get the last REPLEntry in REPLHistory matching the given text
+lastEntry :: Text -> REPLHistory -> Maybe Text
+lastEntry t h =
+  case Seq.viewr $ Seq.filter matchEntry $ h ^. replSeq of
+    Seq.EmptyR -> Nothing
+    _ Seq.:> a -> Just (replItemText a)
+ where
+  matchesText histItem = t `T.isInfixOf` replItemText histItem
+  matchEntry = liftA2 (&&) matchesText isREPLEntry
+
+------------------------------------------------------------
+-- REPL
+------------------------------------------------------------
+
+-- | This data type tells us how to interpret the text typed
+--   by the player at the prompt (which is stored in Editor).
+data REPLPrompt
+  = -- | Interpret the prompt text as a regular command.
+    --   The list is for potential completions, which we can
+    --   cycle through by hitting Tab repeatedly
+    CmdPrompt [Text]
+  | -- | Interpret the prompt text as "search this text in history"
+    SearchPrompt REPLHistory
+
+defaultPrompt :: REPLPrompt
+defaultPrompt = CmdPrompt []
+
+data ReplControlMode
+  = Piloting
+  | Typing
+  deriving (Enum, Bounded, Eq)
+
+data REPLState = REPLState
+  { _replPromptType :: REPLPrompt
+  , _replPromptEditor :: Editor Text Name
+  , _replValid :: Bool
+  , _replLast :: Text
+  , _replType :: Maybe Polytype
+  , _replControlMode :: ReplControlMode
+  , _replHistory :: REPLHistory
+  }
+
+newREPLEditor :: Text -> Editor Text Name
+newREPLEditor t = applyEdit gotoEnd $ editorText REPLInput (Just 1) t
+ where
+  ls = T.lines t
+  pos = (length ls - 1, T.length (last ls))
+  gotoEnd = if null ls then id else TZ.moveCursor pos
+
+initREPLState :: REPLHistory -> REPLState
+initREPLState = REPLState defaultPrompt (newREPLEditor "") True "" Nothing Typing
+
+makeLensesWith (lensRules & generateSignatures .~ False) ''REPLState
+
+-- | The way we interpret text typed by the player in the REPL prompt.
+replPromptType :: Lens' REPLState REPLPrompt
+
+-- | The prompt where the user can type input at the REPL.
+replPromptEditor :: Lens' REPLState (Editor Text Name)
+
+-- | Convinience lens to get text from editor and replace it with new
+--   one that has the provided text.
+replPromptText :: Lens' REPLState Text
+replPromptText = lens g s
+ where
+  g r = r ^. replPromptEditor . to getEditContents . to T.concat
+  s r t = r & replPromptEditor .~ newREPLEditor t
+
+-- | Whether the prompt text is a valid 'Term'.
+replValid :: Lens' REPLState Bool
+
+-- | The type of the current REPL input which should be displayed to
+--   the user (if any).
+replType :: Lens' REPLState (Maybe Polytype)
+
+-- | The last thing the user has typed which isn't part of the history.
+--   This is used to restore the repl form after the user visited the history.
+replLast :: Lens' REPLState Text
+
+-- | Piloting or Typing mode
+replControlMode :: Lens' REPLState ReplControlMode
+
+-- | History of things the user has typed at the REPL, interleaved
+--   with outputs the system has generated.
+replHistory :: Lens' REPLState REPLHistory
diff --git a/src/Swarm/TUI/Model/StateUpdate.hs b/src/Swarm/TUI/Model/StateUpdate.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Model/StateUpdate.hs
@@ -0,0 +1,158 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Swarm.TUI.Model.StateUpdate (
+  initAppState,
+  startGame,
+  restartGame,
+  attainAchievement,
+  attainAchievement',
+  scenarioToAppState,
+) where
+
+import Brick.AttrMap (applyAttrMappings)
+import Control.Applicative ((<|>))
+import Control.Lens hiding (from, (<.>))
+import Control.Monad.Except
+import Control.Monad.State
+import Data.List qualified as List
+import Data.Map qualified as M
+import Data.Maybe (fromMaybe, isJust)
+import Data.Text (Text)
+import Data.Time (ZonedTime, getZonedTime)
+import Swarm.Game.Log (ErrorLevel (..), LogSource (ErrorTrace))
+import Swarm.Game.Scenario (loadScenario, scenarioAttrs)
+import Swarm.Game.Scenario.Objective.Presentation.Model (emptyGoalDisplay)
+import Swarm.Game.ScenarioInfo (
+  ScenarioInfo (..),
+  ScenarioInfoPair,
+  ScenarioStatus (..),
+  normalizeScenarioPath,
+  scenarioItemByPath,
+  scenarioPath,
+  scenarioSolution,
+  scenarioStatus,
+  _SISingle,
+ )
+import Swarm.Game.State
+import Swarm.TUI.Attr (swarmAttrMap)
+import Swarm.TUI.Inventory.Sorting
+import Swarm.TUI.Model
+import Swarm.TUI.Model.Achievement.Attainment
+import Swarm.TUI.Model.Achievement.Definitions
+import Swarm.TUI.Model.Achievement.Persistence
+import Swarm.TUI.Model.Failure (prettyFailure)
+import Swarm.TUI.Model.Repl
+import Swarm.TUI.Model.UI
+import Swarm.TUI.View.CustomStyling (toAttrPair)
+import System.Clock
+
+-- | Initialize the 'AppState'.
+initAppState :: AppOpts -> ExceptT Text IO AppState
+initAppState AppOpts {..} = do
+  let isRunningInitialProgram = isJust scriptToRun || autoPlay
+      skipMenu = isJust userScenario || isRunningInitialProgram || isJust userSeed
+  gs <- initGameState
+  (warnings, ui) <- initUIState (not skipMenu) cheatMode
+  let logWarning rs w = rs & eventLog %~ logEvent (ErrorTrace Error) ("UI Loading", -8) (prettyFailure w)
+  let rs = List.foldl' logWarning initRuntimeState warnings
+  case skipMenu of
+    False -> return $ AppState gs ui rs
+    True -> do
+      (scenario, path) <- loadScenario (fromMaybe "classic" userScenario) (gs ^. entityMap)
+
+      let maybeAutoplay = do
+            guard autoPlay
+            soln <- scenario ^. scenarioSolution
+            return $ SuggestedSolution soln
+      let realToRun = maybeAutoplay <|> (ScriptPath <$> scriptToRun)
+
+      execStateT
+        (startGameWithSeed userSeed (scenario, ScenarioInfo path NotStarted NotStarted NotStarted) realToRun)
+        (AppState gs ui rs)
+
+-- | Load a 'Scenario' and start playing the game.
+startGame :: (MonadIO m, MonadState AppState m) => ScenarioInfoPair -> Maybe CodeToRun -> m ()
+startGame = startGameWithSeed Nothing
+
+-- | Re-initialize the game from the stored reference to the current scenario.
+--
+-- Note that "restarting" is intended only for "scenarios";
+-- with some scenarios, it may be possible to get stuck so that it is
+-- either impossible or very annoying to win, so being offered an
+-- option to restart is more user-friendly.
+--
+-- Since scenarios are stored as a Maybe in the UI state, we handle the Nothing
+-- case upstream so that the Scenario passed to this function definitely exists.
+restartGame :: (MonadIO m, MonadState AppState m) => Seed -> ScenarioInfoPair -> m ()
+restartGame currentSeed siPair = startGameWithSeed (Just currentSeed) siPair Nothing
+
+-- | Load a 'Scenario' and start playing the game, with the
+--   possibility for the user to override the seed.
+startGameWithSeed :: (MonadIO m, MonadState AppState m) => Maybe Seed -> ScenarioInfoPair -> Maybe CodeToRun -> m ()
+startGameWithSeed userSeed siPair@(_scene, si) toRun = do
+  t <- liftIO getZonedTime
+  ss <- use $ gameState . scenarios
+  p <- liftIO $ normalizeScenarioPath ss (si ^. scenarioPath)
+  gameState . currentScenarioPath .= Just p
+  gameState . scenarios . scenarioItemByPath p . _SISingle . _2 . scenarioStatus .= InProgress t 0 0
+  scenarioToAppState siPair userSeed toRun
+
+-- TODO: #516 do we need to keep an old entity map around???
+
+-- | Modify the 'AppState' appropriately when starting a new scenario.
+scenarioToAppState ::
+  (MonadIO m, MonadState AppState m) =>
+  ScenarioInfoPair ->
+  Maybe Seed ->
+  Maybe CodeToRun ->
+  m ()
+scenarioToAppState siPair@(scene, _) userSeed toRun = do
+  withLensIO gameState $ scenarioToGameState scene userSeed toRun
+  withLensIO uiState $ scenarioToUIState siPair
+ where
+  withLensIO :: (MonadIO m, MonadState AppState m) => Lens' AppState x -> (x -> IO x) -> m ()
+  withLensIO l a = do
+    x <- use l
+    x' <- liftIO $ a x
+    l .= x'
+
+attainAchievement :: (MonadIO m, MonadState AppState m) => CategorizedAchievement -> m ()
+attainAchievement a = do
+  currentTime <- liftIO getZonedTime
+  attainAchievement' currentTime Nothing a
+
+attainAchievement' ::
+  (MonadIO m, MonadState AppState m) =>
+  ZonedTime ->
+  Maybe FilePath ->
+  CategorizedAchievement ->
+  m ()
+attainAchievement' t p a = do
+  (uiState . uiAchievements)
+    %= M.insertWith
+      (<>)
+      a
+      (Attainment a p t)
+  newAchievements <- use $ uiState . uiAchievements
+  liftIO $ saveAchievementsInfo $ M.elems newAchievements
+
+-- | Modify the UI state appropriately when starting a new scenario.
+scenarioToUIState :: ScenarioInfoPair -> UIState -> IO UIState
+scenarioToUIState siPair u = do
+  curTime <- getTime Monotonic
+  return $
+    u
+      & uiPlaying .~ True
+      & uiGoal .~ emptyGoalDisplay
+      & uiFocusRing .~ initFocusRing
+      & uiInventory .~ Nothing
+      & uiInventorySort .~ defaultSortOptions
+      & uiShowFPS .~ False
+      & uiShowZero .~ True
+      & lgTicksPerSecond .~ initLgTicksPerSecond
+      & uiREPL .~ initREPLState (u ^. uiREPL . replHistory)
+      & uiREPL . replHistory %~ restartREPLHistory
+      & uiAttrMap .~ applyAttrMappings (map toAttrPair $ fst siPair ^. scenarioAttrs) swarmAttrMap
+      & scenarioRef ?~ siPair
+      & lastFrameTime .~ curTime
diff --git a/src/Swarm/TUI/Model/UI.hs b/src/Swarm/TUI/Model/UI.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/Model/UI.hs
@@ -0,0 +1,312 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE RecordWildCards #-}
+{-# LANGUAGE TemplateHaskell #-}
+{-# LANGUAGE ViewPatterns #-}
+
+module Swarm.TUI.Model.UI (
+  UIState (..),
+  GoalDisplay (..),
+  uiMenu,
+  uiPlaying,
+  uiCheatMode,
+  uiFocusRing,
+  uiWorldCursor,
+  uiREPL,
+  uiInventory,
+  uiInventorySort,
+  uiMoreInfoTop,
+  uiMoreInfoBot,
+  uiScrollToEnd,
+  uiError,
+  uiModal,
+  uiGoal,
+  uiAchievements,
+  lgTicksPerSecond,
+  lastFrameTime,
+  accumulatedTime,
+  tickCount,
+  frameCount,
+  frameTickCount,
+  lastInfoTime,
+  uiShowFPS,
+  uiShowZero,
+  uiShowRobots,
+  uiHideRobotsUntil,
+  uiInventoryShouldUpdate,
+  uiTPF,
+  uiFPS,
+  uiAttrMap,
+  scenarioRef,
+  appData,
+
+  -- ** Initialization
+  initFocusRing,
+  initLgTicksPerSecond,
+  initUIState,
+) where
+
+import Brick (AttrMap)
+import Brick.Focus
+import Brick.Widgets.List qualified as BL
+import Control.Arrow ((&&&))
+import Control.Lens hiding (from, (<.>))
+import Control.Monad.Except
+import Data.Bits (FiniteBits (finiteBitSize))
+import Data.Map (Map)
+import Data.Map qualified as M
+import Data.Text (Text)
+import Data.Text qualified as T
+import Swarm.Game.Scenario.Objective.Presentation.Model
+import Swarm.Game.ScenarioInfo (
+  ScenarioInfoPair,
+ )
+import Swarm.Game.World qualified as W
+import Swarm.TUI.Attr (swarmAttrMap)
+import Swarm.TUI.Inventory.Sorting
+import Swarm.TUI.Model.Achievement.Attainment
+import Swarm.TUI.Model.Achievement.Definitions
+import Swarm.TUI.Model.Achievement.Persistence
+import Swarm.TUI.Model.Failure (SystemFailure)
+import Swarm.TUI.Model.Menu
+import Swarm.TUI.Model.Name
+import Swarm.TUI.Model.Repl
+import Swarm.Util
+import System.Clock
+
+------------------------------------------------------------
+-- UI state
+------------------------------------------------------------
+
+-- | The main record holding the UI state.  For access to the fields,
+-- see the lenses below.
+data UIState = UIState
+  { _uiMenu :: Menu
+  , _uiPlaying :: Bool
+  , _uiCheatMode :: Bool
+  , _uiFocusRing :: FocusRing Name
+  , _uiWorldCursor :: Maybe W.Coords
+  , _uiREPL :: REPLState
+  , _uiInventory :: Maybe (Int, BL.List Name InventoryListEntry)
+  , _uiInventorySort :: InventorySortOptions
+  , _uiMoreInfoTop :: Bool
+  , _uiMoreInfoBot :: Bool
+  , _uiScrollToEnd :: Bool
+  , _uiError :: Maybe Text
+  , _uiModal :: Maybe Modal
+  , _uiGoal :: GoalDisplay
+  , _uiAchievements :: Map CategorizedAchievement Attainment
+  , _uiShowFPS :: Bool
+  , _uiShowZero :: Bool
+  , _uiHideRobotsUntil :: TimeSpec
+  , _uiInventoryShouldUpdate :: Bool
+  , _uiTPF :: Double
+  , _uiFPS :: Double
+  , _lgTicksPerSecond :: Int
+  , _tickCount :: Int
+  , _frameCount :: Int
+  , _frameTickCount :: Int
+  , _lastFrameTime :: TimeSpec
+  , _accumulatedTime :: TimeSpec
+  , _lastInfoTime :: TimeSpec
+  , _appData :: Map Text Text
+  , _uiAttrMap :: AttrMap
+  , _scenarioRef :: Maybe ScenarioInfoPair
+  }
+
+--------------------------------------------------
+-- Lenses for UIState
+
+let exclude = ['_lgTicksPerSecond]
+ in makeLensesWith
+      ( lensRules
+          & generateSignatures .~ False
+          & lensField . mapped . mapped %~ \fn n ->
+            if n `elem` exclude then [] else fn n
+      )
+      ''UIState
+
+-- | The current menu state.
+uiMenu :: Lens' UIState Menu
+
+-- | Are we currently playing the game?  True = we are playing, and
+--   should thus display a world, REPL, etc.; False = we should
+--   display the current menu.
+uiPlaying :: Lens' UIState Bool
+
+-- | Cheat mode, i.e. are we allowed to turn creative mode on and off?
+uiCheatMode :: Lens' UIState Bool
+
+-- | The focus ring is the set of UI panels we can cycle among using
+--   the Tab key.
+uiFocusRing :: Lens' UIState (FocusRing Name)
+
+-- | The last clicked position on the world view.
+uiWorldCursor :: Lens' UIState (Maybe W.Coords)
+
+-- | The state of REPL panel.
+uiREPL :: Lens' UIState REPLState
+
+-- | The order and direction of sorting inventory list.
+uiInventorySort :: Lens' UIState InventorySortOptions
+
+-- | The hash value of the focused robot entity (so we can tell if its
+--   inventory changed) along with a list of the items in the
+--   focused robot's inventory.
+uiInventory :: Lens' UIState (Maybe (Int, BL.List Name InventoryListEntry))
+
+-- | Does the info panel contain more content past the top of the panel?
+uiMoreInfoTop :: Lens' UIState Bool
+
+-- | Does the info panel contain more content past the bottom of the panel?
+uiMoreInfoBot :: Lens' UIState Bool
+
+-- | A flag telling the UI to scroll the info panel to the very end
+--   (used when a new log message is appended).
+uiScrollToEnd :: Lens' UIState Bool
+
+-- | When this is @Just@, it represents a popup box containing an
+--   error message that is shown on top of the rest of the UI.
+uiError :: Lens' UIState (Maybe Text)
+
+-- | When this is @Just@, it represents a modal to be displayed on
+--   top of the UI, e.g. for the Help screen.
+uiModal :: Lens' UIState (Maybe Modal)
+
+-- | Status of the scenario goal: whether there is one, and whether it
+--   has been displayed to the user initially.
+uiGoal :: Lens' UIState GoalDisplay
+
+-- | Map of achievements that were attained
+uiAchievements :: Lens' UIState (Map CategorizedAchievement Attainment)
+
+-- | A toggle to show the FPS by pressing `f`
+uiShowFPS :: Lens' UIState Bool
+
+-- | A toggle to show or hide inventory items with count 0 by pressing `0`
+uiShowZero :: Lens' UIState Bool
+
+-- | Hide robots on the world map.
+uiHideRobotsUntil :: Lens' UIState TimeSpec
+
+-- | Whether to show or hide robots on the world map.
+uiShowRobots :: Getter UIState Bool
+uiShowRobots = to (\ui -> ui ^. lastFrameTime > ui ^. uiHideRobotsUntil)
+
+-- | Whether the Inventory ui panel should update
+uiInventoryShouldUpdate :: Lens' UIState Bool
+
+-- | Computed ticks per milli seconds
+uiTPF :: Lens' UIState Double
+
+-- | Computed frames per milli seconds
+uiFPS :: Lens' UIState Double
+
+-- | Attribute map
+uiAttrMap :: Lens' UIState AttrMap
+
+-- | The currently active Scenario description, useful for starting over.
+scenarioRef :: Lens' UIState (Maybe ScenarioInfoPair)
+
+-- | The base-2 logarithm of the current game speed in ticks/second.
+--   Note that we cap this value to the range of +/- log2 INTMAX.
+lgTicksPerSecond :: Lens' UIState Int
+lgTicksPerSecond = lens _lgTicksPerSecond safeSetLgTicks
+ where
+  maxLog = finiteBitSize (maxBound :: Int)
+  maxTicks = maxLog - 2
+  minTicks = 2 - maxLog
+  safeSetLgTicks ui lTicks
+    | lTicks < minTicks = setLgTicks ui minTicks
+    | lTicks > maxTicks = setLgTicks ui maxTicks
+    | otherwise = setLgTicks ui lTicks
+  setLgTicks ui lTicks = ui {_lgTicksPerSecond = lTicks}
+
+-- | A counter used to track how many ticks have happened since the
+--   last time we updated the ticks/frame statistics.
+tickCount :: Lens' UIState Int
+
+-- | A counter used to track how many frames have been rendered since the
+--   last time we updated the ticks/frame statistics.
+frameCount :: Lens' UIState Int
+
+-- | A counter used to track how many ticks have happened in the
+--   current frame, so we can stop when we get to the tick cap.
+frameTickCount :: Lens' UIState Int
+
+-- | The time of the last info widget update
+lastInfoTime :: Lens' UIState TimeSpec
+
+-- | The time of the last 'Frame' event.
+lastFrameTime :: Lens' UIState TimeSpec
+
+-- | The amount of accumulated real time.  Every time we get a 'Frame'
+--   event, we accumulate the amount of real time that happened since
+--   the last frame, then attempt to take an appropriate number of
+--   ticks to "catch up", based on the target tick rate.
+--
+--   See https://gafferongames.com/post/fix_your_timestep/ .
+accumulatedTime :: Lens' UIState TimeSpec
+
+-- | Free-form data loaded from the @data@ directory, for things like
+--   the logo, about page, tutorial story, etc.
+appData :: Lens' UIState (Map Text Text)
+
+--------------------------------------------------
+-- UIState initialization
+
+-- | The initial state of the focus ring.
+initFocusRing :: FocusRing Name
+initFocusRing = focusRing $ map FocusablePanel listEnums
+
+-- | The initial tick speed.
+initLgTicksPerSecond :: Int
+initLgTicksPerSecond = 4 -- 2^4 = 16 ticks / second
+
+-- | Initialize the UI state.  This needs to be in the IO monad since
+--   it involves reading a REPL history file, getting the current
+--   time, and loading text files from the data directory.  The @Bool@
+--   parameter indicates whether we should start off by showing the
+--   main menu.
+initUIState :: Bool -> Bool -> ExceptT Text IO ([SystemFailure], UIState)
+initUIState showMainMenu cheatMode = do
+  historyT <- liftIO $ readFileMayT =<< getSwarmHistoryPath False
+  appDataMap <- liftIO readAppData
+  let history = maybe [] (map REPLEntry . T.lines) historyT
+  startTime <- liftIO $ getTime Monotonic
+  (warnings, achievements) <- liftIO loadAchievementsInfo
+  let out =
+        UIState
+          { _uiMenu = if showMainMenu then MainMenu (mainMenu NewGame) else NoMenu
+          , _uiPlaying = not showMainMenu
+          , _uiCheatMode = cheatMode
+          , _uiFocusRing = initFocusRing
+          , _uiWorldCursor = Nothing
+          , _uiREPL = initREPLState $ newREPLHistory history
+          , _uiInventory = Nothing
+          , _uiInventorySort = defaultSortOptions
+          , _uiMoreInfoTop = False
+          , _uiMoreInfoBot = False
+          , _uiScrollToEnd = False
+          , _uiError = Nothing
+          , _uiModal = Nothing
+          , _uiGoal = emptyGoalDisplay
+          , _uiAchievements = M.fromList $ map (view achievement &&& id) achievements
+          , _uiShowFPS = False
+          , _uiShowZero = True
+          , _uiHideRobotsUntil = startTime - 1
+          , _uiInventoryShouldUpdate = False
+          , _uiTPF = 0
+          , _uiFPS = 0
+          , _lgTicksPerSecond = initLgTicksPerSecond
+          , _lastFrameTime = startTime
+          , _accumulatedTime = 0
+          , _lastInfoTime = 0
+          , _tickCount = 0
+          , _frameCount = 0
+          , _frameTickCount = 0
+          , _appData = appDataMap
+          , _uiAttrMap = swarmAttrMap
+          , _scenarioRef = Nothing
+          }
+  return (warnings, out)
diff --git a/src/Swarm/TUI/View.hs b/src/Swarm/TUI/View.hs
--- a/src/Swarm/TUI/View.hs
+++ b/src/Swarm/TUI/View.hs
@@ -38,7 +38,7 @@
   drawREPL,
 ) where
 
-import Brick hiding (Direction)
+import Brick hiding (Direction, Location)
 import Brick.Focus
 import Brick.Forms
 import Brick.Widgets.Border (hBorder, hBorderWithLabel, joinableBorder, vBorder)
@@ -49,7 +49,6 @@
 import Brick.Widgets.Table qualified as BT
 import Control.Lens hiding (Const, from)
 import Control.Monad (guard)
-import Control.Monad.Reader (withReaderT)
 import Data.Array (range)
 import Data.Bits (shiftL, shiftR, (.&.))
 import Data.Foldable qualified as F
@@ -68,8 +67,8 @@
 import Data.Text (Text)
 import Data.Text qualified as T
 import Data.Time (NominalDiffTime, defaultTimeLocale, formatTime)
-import Graphics.Vty qualified as V
 import Linear
+import Linear.Affine (Point)
 import Network.Wai.Handler.Warp (Port)
 import Swarm.Game.CESK (CESK (..))
 import Swarm.Game.Display
@@ -77,6 +76,8 @@
 import Swarm.Game.Recipe
 import Swarm.Game.Robot
 import Swarm.Game.Scenario (scenarioAuthor, scenarioDescription, scenarioName, scenarioObjectives)
+import Swarm.Game.Scenario.Objective.Presentation.Model (goalsContent, hasAnythingToShow)
+import Swarm.Game.Scenario.Objective.Presentation.Render qualified as GR
 import Swarm.Game.ScenarioInfo (
   ScenarioItem (..),
   ScenarioStatus (..),
@@ -86,18 +87,23 @@
   scenarioStatus,
  )
 import Swarm.Game.State
-import Swarm.Game.Terrain (terrainMap)
 import Swarm.Game.World qualified as W
+import Swarm.Language.Capability (constCaps)
 import Swarm.Language.Pretty (prettyText)
 import Swarm.Language.Syntax
 import Swarm.Language.Typecheck (inferConst)
-import Swarm.Language.Types (Polytype)
 import Swarm.TUI.Attr
 import Swarm.TUI.Border
 import Swarm.TUI.Inventory.Sorting (renderSortMethod)
 import Swarm.TUI.Model
+import Swarm.TUI.Model.Repl
+import Swarm.TUI.Model.UI
 import Swarm.TUI.Panel
+import Swarm.TUI.View.Achievement
+import Swarm.TUI.View.CellDisplay
+import Swarm.TUI.View.Util
 import Swarm.Util
+import Swarm.Util.Location
 import Swarm.Version (NewReleaseFailure (..))
 import System.Clock (TimeSpec (..))
 import Text.Printf
@@ -110,19 +116,20 @@
 drawUI s
   | s ^. uiState . uiPlaying = drawGameUI s
   | otherwise = case s ^. uiState . uiMenu of
-    -- We should never reach the NoMenu case if uiPlaying is false; we would have
-    -- quit the app instead.  But just in case, we display the main menu anyway.
-    NoMenu -> [drawMainMenuUI s (mainMenu NewGame)]
-    MainMenu l -> [drawMainMenuUI s l]
-    NewGameMenu stk -> [drawNewGameMenuUI stk]
-    MessagesMenu -> [drawMainMessages s]
-    AboutMenu -> [drawAboutMenuUI (s ^. uiState . appData . at "about")]
+      -- We should never reach the NoMenu case if uiPlaying is false; we would have
+      -- quit the app instead.  But just in case, we display the main menu anyway.
+      NoMenu -> [drawMainMenuUI s (mainMenu NewGame)]
+      MainMenu l -> [drawMainMenuUI s l]
+      NewGameMenu stk -> [drawNewGameMenuUI stk]
+      AchievementsMenu l -> [drawAchievementsMenuUI s l]
+      MessagesMenu -> [drawMainMessages s]
+      AboutMenu -> [drawAboutMenuUI (s ^. uiState . appData . at "about")]
 
 drawMainMessages :: AppState -> Widget Name
 drawMainMessages s = renderDialog dial . padBottom Max . scrollList $ drawLogs ls
  where
   ls = reverse $ s ^. runtimeState . eventLog . notificationsContent
-  dial = dialog (Just "Messages") Nothing maxModalWindowWidth
+  dial = dialog (Just $ str "Messages") Nothing maxModalWindowWidth
   scrollList = withVScrollBars OnRight . vBox
   drawLogs = map (drawLogEntry True)
 
@@ -131,7 +138,7 @@
   vBox . catMaybes $
     [ drawLogo <$> logo
     , hCenter . padTopBottom 2 <$> newVersionWidget version
-    , Just . centerLayer . vLimit 5 . hLimit 20 $
+    , Just . centerLayer . vLimit 6 . hLimit 20 $
         BL.renderList (const (hCenter . drawMainMenuEntry s)) True l
     ]
  where
@@ -169,7 +176,8 @@
       [ vBox
           [ withAttr boldAttr . txt $ breadcrumbs ls
           , txt " "
-          , vLimit 20 . hLimit 35
+          , vLimit 20
+              . hLimit 35
               . BL.renderList (const $ padRight Max . drawScenarioItem) True
               $ l
           ]
@@ -244,6 +252,7 @@
 drawMainMenuEntry s = \case
   NewGame -> txt "New game"
   Tutorial -> txt "Tutorial"
+  Achievements -> txt "Achievements"
   About -> txt "About"
   Messages -> highlightMessages $ txt "Messages"
   Quit -> txt "Quit"
@@ -271,16 +280,16 @@
       hBox
         [ hLimitPercent 25 $
             vBox
-              [ vLimitPercent 50 $ panel highlightAttr fr RobotPanel plainBorder $ drawRobotPanel s
+              [ vLimitPercent 50 $ panel highlightAttr fr (FocusablePanel RobotPanel) plainBorder $ drawRobotPanel s
               , panel
                   highlightAttr
                   fr
-                  InfoPanel
+                  (FocusablePanel InfoPanel)
                   ( plainBorder
                       & topLabels . centerLabel
-                      .~ (if moreTop then Just (txt " · · · ") else Nothing)
+                        .~ (if moreTop then Just (txt " · · · ") else Nothing)
                       & bottomLabels . centerLabel
-                      .~ (if moreBot then Just (txt " · · · ") else Nothing)
+                        .~ (if moreBot then Just (txt " · · · ") else Nothing)
                   )
                   $ drawInfoPanel s
               ]
@@ -288,7 +297,7 @@
             [ panel
                 highlightAttr
                 fr
-                WorldPanel
+                (FocusablePanel WorldPanel)
                 ( plainBorder
                     & bottomLabels . rightLabel ?~ padLeftRight 1 (drawTPS s)
                     & topLabels . leftLabel ?~ drawModalMenu s
@@ -297,11 +306,11 @@
                 )
                 (drawWorld (s ^. uiState . uiShowRobots) (s ^. gameState))
             , drawKeyMenu s
-            , clickable REPLPanel $
+            , clickable (FocusablePanel REPLPanel) $
                 panel
                   highlightAttr
                   fr
-                  REPLPanel
+                  (FocusablePanel REPLPanel)
                   ( plainBorder
                       & topLabels . rightLabel .~ (drawType <$> (s ^. uiState . uiREPL . replType))
                   )
@@ -317,19 +326,41 @@
   addCursorPos = case s ^. uiState . uiWorldCursor of
     Nothing -> id
     Just coord ->
-      let worlCursorInfo = drawWorldCursorInfo (s ^. uiState . uiShowRobots) (s ^. gameState) coord
-       in bottomLabels . leftLabel ?~ padLeftRight 1 worlCursorInfo
+      let worldCursorInfo = drawWorldCursorInfo (s ^. gameState) coord
+       in bottomLabels . leftLabel ?~ padLeftRight 1 worldCursorInfo
   -- Add clock display in top right of the world view if focused robot
-  -- has a clock installed
+  -- has a clock equipped
   addClock = topLabels . rightLabel ?~ padLeftRight 1 (drawClockDisplay $ s ^. gameState)
   fr = s ^. uiState . uiFocusRing
   moreTop = s ^. uiState . uiMoreInfoTop
   moreBot = s ^. uiState . uiMoreInfoBot
 
-drawWorldCursorInfo :: Bool -> GameState -> W.Coords -> Widget Name
-drawWorldCursorInfo showRobots g i@(W.Coords (y, x)) =
-  hBox [drawLoc showRobots g i, txt $ " at " <> from (show x) <> " " <> from (show (y * (-1)))]
+drawWorldCursorInfo :: GameState -> W.Coords -> Widget Name
+drawWorldCursorInfo g coords@(W.Coords (y, x)) =
+  hBox $ tileMemberWidgets ++ [coordsWidget]
+ where
+  coordsWidget =
+    txt $
+      T.unwords
+        [ from $ show x
+        , from $ show $ y * (-1)
+        ]
 
+  tileMembers = terrain : mapMaybe merge [entity, robot]
+  tileMemberWidgets =
+    map (padRight $ Pad 1) $
+      concat $
+        reverse $
+          zipWith f tileMembers ["at", "on", "with"]
+   where
+    f cell preposition = [renderDisplay cell, txt preposition]
+
+  terrain = displayTerrainCell g coords
+  entity = displayEntityCell g coords
+  robot = displayRobotCell g coords
+
+  merge = fmap sconcat . NE.nonEmpty . filter (not . (^. invisible))
+
 -- | Format the clock display to be shown in the upper right of the
 --   world panel.
 drawClockDisplay :: GameState -> Widget n
@@ -339,12 +370,12 @@
   pauseWidget = guard (gs ^. paused) $> txt "(PAUSED)"
 
 -- | Check whether the currently focused robot (if any) has a clock
---   device installed.
-clockInstalled :: GameState -> Bool
-clockInstalled gs = case focusedRobot gs of
+--   device equipped.
+clockEquipped :: GameState -> Bool
+clockEquipped gs = case focusedRobot gs of
   Nothing -> False
   Just r
-    | countByName "clock" (r ^. installedDevices) > 0 -> True
+    | countByName "clock" (r ^. equippedDevices) > 0 -> True
     | otherwise -> False
 
 -- | Format a ticks count as a hexadecimal clock.
@@ -360,16 +391,12 @@
       ++ if showTicks then [".", printf "%x" (t .&. ((1 `shiftL` 4) - 1))] else []
 
 -- | Return a possible time display, if the currently focused robot
---   has a clock device installed.  The first argument is the number
+--   has a clock device equipped.  The first argument is the number
 --   of ticks (e.g. 943 = 0x3af), and the second argument indicates
 --   whether the time should be shown down to single-tick resolution
 --   (e.g. 0:00:3a.f) or not (e.g. 0:00:3a).
 maybeDrawTime :: Integer -> Bool -> GameState -> Maybe (Widget n)
-maybeDrawTime t showTicks gs = guard (clockInstalled gs) $> drawTime t showTicks
-
--- | Render the type of the current REPL input to be shown to the user.
-drawType :: Polytype -> Widget Name
-drawType = withAttr infoAttr . padLeftRight 1 . txt . prettyText
+maybeDrawTime t showTicks gs = guard (clockEquipped gs) $> drawTime t showTicks
 
 -- | Draw info about the current number of ticks per second.
 drawTPS :: AppState -> Widget Name
@@ -381,12 +408,12 @@
 
   rateInfo
     | s ^. uiState . uiShowFPS =
-      [ txt " ("
-      , str (printf "%0.1f" (s ^. uiState . uiTPF))
-      , txt " tpf, "
-      , str (printf "%0.1f" (s ^. uiState . uiFPS))
-      , txt " fps)"
-      ]
+        [ txt " ("
+        , str (printf "%0.1f" (s ^. uiState . uiTPF))
+        , txt " tpf, "
+        , str (printf "%0.1f" (s ^. uiState . uiFPS))
+        , txt " fps)"
+        ]
     | otherwise = []
 
   l = s ^. uiState . lgTicksPerSecond
@@ -403,13 +430,9 @@
   Nothing -> showFirstCursor s locs
   Just _ -> Nothing
 
--- | Width cap for modal and error message windows
-maxModalWindowWidth :: Int
-maxModalWindowWidth = 500
-
 -- | Render the error dialog window with a given error message
 renderErrorDialog :: Text -> Widget Name
-renderErrorDialog err = renderDialog (dialog (Just "Error") Nothing (maxModalWindowWidth `min` requiredWidth)) errContent
+renderErrorDialog err = renderDialog (dialog (Just $ str "Error") Nothing (maxModalWindowWidth `min` requiredWidth)) errContent
  where
   errContent = txtWrapWith indent2 {preserveIndentation = True} err
   requiredWidth = 2 + maximum (textWidth <$> T.lines err)
@@ -417,110 +440,33 @@
 -- | Draw the error dialog window, if it should be displayed right now.
 drawDialog :: AppState -> Widget Name
 drawDialog s = case s ^. uiState . uiModal of
-  Just (Modal mt d) -> renderDialog d (maybeScroll ModalViewport $ drawModal s mt)
+  Just (Modal mt d) -> renderDialog d $ case mt of
+    GoalModal -> drawModal s mt
+    _ -> maybeScroll ModalViewport $ drawModal s mt
   Nothing -> maybe emptyWidget renderErrorDialog (s ^. uiState . uiError)
 
--- | Make a widget scrolling if it is bigger than the available
---   vertical space.  Thanks to jtdaugherty for this code.
-maybeScroll :: (Ord n, Show n) => n -> Widget n -> Widget n
-maybeScroll vpName contents =
-  Widget Greedy Greedy $ do
-    ctx <- getContext
-    result <- withReaderT (availHeightL .~ 10000) (render contents)
-    if V.imageHeight (result ^. imageL) <= ctx ^. availHeightL
-      then return result
-      else
-        render $
-          withVScrollBars OnRight $
-            viewport vpName Vertical $
-              Widget Fixed Fixed $
-                return result
-
 -- | Draw one of the various types of modal dialog.
 drawModal :: AppState -> ModalType -> Widget Name
 drawModal s = \case
   HelpModal -> helpWidget (s ^. gameState . seed) (s ^. runtimeState . webPort)
   RobotsModal -> robotsListWidget s
   RecipesModal -> availableListWidget (s ^. gameState) RecipeList
-  CommandsModal -> availableListWidget (s ^. gameState) CommandList
+  CommandsModal -> commandsListWidget (s ^. gameState)
   MessagesModal -> availableListWidget (s ^. gameState) MessageList
   WinModal -> padBottom (Pad 1) $ hCenter $ txt "Congratulations!"
+  LoseModal ->
+    padBottom (Pad 1) $
+      vBox $
+        map
+          (hCenter . txt)
+          [ "Condolences!"
+          , "This scenario is no longer winnable."
+          ]
   DescriptionModal e -> descriptionWidget s e
   QuitModal -> padBottom (Pad 1) $ hCenter $ txt (quitMsg (s ^. uiState . uiMenu))
-  GoalModal g -> padLeftRight 1 (displayParagraphs g)
+  GoalModal -> GR.renderGoalsDisplay (s ^. uiState . uiGoal)
   KeepPlayingModal -> padLeftRight 1 (displayParagraphs ["Have fun!  Hit Ctrl-Q whenever you're ready to proceed to the next challenge or return to the menu."])
 
-quitMsg :: Menu -> Text
-quitMsg m = "Are you sure you want to " <> quitAction <> "? All progress on this scenario will be lost!"
- where
-  quitAction = case m of
-    NoMenu -> "quit"
-    _ -> "return to the menu"
-
--- | Generate a fresh modal window of the requested type.
-generateModal :: AppState -> ModalType -> Modal
-generateModal s mt = Modal mt (dialog (Just title) buttons (maxModalWindowWidth `min` requiredWidth))
- where
-  currentScenario = s ^. uiState . scenarioRef
-  currentSeed = s ^. gameState . seed
-  haltingMessage = case s ^. uiState . uiMenu of
-    NoMenu -> Just "Quit"
-    _ -> Nothing
-  descriptionWidth = 100
-  helpWidth = 80
-  (title, buttons, requiredWidth) =
-    case mt of
-      HelpModal -> (" Help ", Nothing, helpWidth)
-      RobotsModal -> ("Robots", Nothing, descriptionWidth)
-      RecipesModal -> ("Available Recipes", Nothing, descriptionWidth)
-      CommandsModal -> ("Available Commands", Nothing, descriptionWidth)
-      MessagesModal -> ("Messages", Nothing, descriptionWidth)
-      WinModal ->
-        let nextMsg = "Next challenge!"
-            stopMsg = fromMaybe "Return to the menu" haltingMessage
-            continueMsg = "Keep playing"
-         in ( ""
-            , Just
-                ( 0
-                , [ (nextMsg, NextButton scene)
-                  | Just scene <- [nextScenario (s ^. uiState . uiMenu)]
-                  ]
-                    ++ [ (stopMsg, QuitButton)
-                       , (continueMsg, KeepPlayingButton)
-                       ]
-                )
-            , sum (map length [nextMsg, stopMsg, continueMsg]) + 32
-            )
-      DescriptionModal e -> (descriptionTitle e, Nothing, descriptionWidth)
-      QuitModal ->
-        let stopMsg = fromMaybe ("Quit to" ++ maybe "" (" " ++) (into @String <$> curMenuName s) ++ " menu") haltingMessage
-            maybeStartOver = sequenceA ("Start over", StartOverButton currentSeed <$> currentScenario)
-         in ( ""
-            , Just
-                ( 0
-                , catMaybes
-                    [ Just ("Keep playing", CancelButton)
-                    , maybeStartOver
-                    , Just (stopMsg, QuitButton)
-                    ]
-                )
-            , T.length (quitMsg (s ^. uiState . uiMenu)) + 4
-            )
-      GoalModal _ ->
-        let goalModalTitle = case currentScenario of
-              Nothing -> "Goal"
-              Just (scenario, _) -> scenario ^. scenarioName
-         in (" " <> T.unpack goalModalTitle <> " ", Nothing, 80)
-      KeepPlayingModal -> ("", Just (0, [("OK", CancelButton)]), 80)
-
--- | Get the name of the current New Game menu.
-curMenuName :: AppState -> Maybe Text
-curMenuName s = case s ^. uiState . uiMenu of
-  NewGameMenu (_ :| (parentMenu : _)) ->
-    Just (parentMenu ^. BL.listSelectedElementL . to scenarioItemName)
-  NewGameMenu _ -> Just "Scenarios"
-  _ -> Nothing
-
 robotsListWidget :: AppState -> Widget Name
 robotsListWidget s = hCenter table
  where
@@ -571,7 +517,7 @@
 
     locWidget = hBox [worldCell, txt $ " " <> locStr]
      where
-      rloc@(V2 x y) = robot ^. robotLocation
+      rloc@(Location x y) = robot ^. robotLocation
       worldCell = drawLoc (s ^. uiState . uiShowRobots) g (W.locToCoords rloc)
       locStr = from (show x) <> " " <> from (show y)
 
@@ -581,9 +527,9 @@
         | isActive robot -> withAttr notifAttr $ txt "busy"
         | otherwise -> withAttr greenAttr $ txt "idle"
 
-  basePos :: V2 Double
-  basePos = realToFrac <$> fromMaybe (V2 0 0) (g ^? baseRobot . robotLocation)
-  -- Keep the base and non sytem robot (e.g. no seed)
+  basePos :: Point V2 Double
+  basePos = realToFrac <$> fromMaybe origin (g ^? baseRobot . robotLocation)
+  -- Keep the base and non system robot (e.g. no seed)
   isRelevant robot = robot ^. robotID == 0 || not (robot ^. systemRobot)
   -- Keep the robot that are less than 32 unit away from the base
   isNear robot = creative || distance (realToFrac <$> robot ^. robotLocation) basePos < 32
@@ -649,18 +595,16 @@
     , ("Meta-t", "focus on the info panel")
     ]
 
-data NotificationList = RecipeList | CommandList | MessageList
+data NotificationList = RecipeList | MessageList
 
 availableListWidget :: GameState -> NotificationList -> Widget Name
 availableListWidget gs nl = padTop (Pad 1) $ vBox widgetList
  where
   widgetList = case nl of
     RecipeList -> mkAvailableList gs availableRecipes renderRecipe
-    CommandList -> mkAvailableList gs availableCommands renderCommand & (<> constWiki) . (padLeftRight 18 constHeader :)
     MessageList -> messagesWidget gs
   renderRecipe = padLeftRight 18 . drawRecipe Nothing (fromMaybe E.empty inv)
   inv = gs ^? to focusedRobot . _Just . robotInventory
-  renderCommand = padLeftRight 18 . drawConst
 
 mkAvailableList :: GameState -> Lens' GameState (Notifications a) -> (a -> Widget Name) -> [Widget Name]
 mkAvailableList gs notifLens notifRender = map padRender news <> notifSep <> map padRender knowns
@@ -670,28 +614,59 @@
   (news, knowns) = splitAt count (gs ^. notifLens . notificationsContent)
   notifSep
     | count > 0 && not (null knowns) =
-      [ padBottom (Pad 1) (withAttr redAttr $ hBorderWithLabel (padLeftRight 1 (txt "new↑")))
-      ]
+        [ padBottom (Pad 1) (withAttr redAttr $ hBorderWithLabel (padLeftRight 1 (txt "new↑")))
+        ]
     | otherwise = []
 
-constHeader :: Widget Name
-constHeader = padBottom (Pad 1) $ withAttr robotAttr $ padLeft (Pad 1) $ txt "command name : type"
+commandsListWidget :: GameState -> Widget Name
+commandsListWidget gs =
+  hCenter $
+    vBox
+      [ table
+      , padTop (Pad 1) $ txt "For the full list of available commands see the Wiki at:"
+      , txt "https://github.com/swarm-game/swarm/wiki/Commands-Cheat-Sheet"
+      ]
+ where
+  commands = gs ^. availableCommands . notificationsContent
+  table =
+    BT.renderTable
+      . BT.surroundingBorder False
+      . BT.columnBorders False
+      . BT.rowBorders False
+      . BT.setDefaultColAlignment BT.AlignLeft
+      . BT.alignRight 0
+      . BT.table
+      $ headers : commandsTable
+  headers =
+    withAttr robotAttr
+      <$> [ txt "command name"
+          , txt " : type"
+          , txt "Enabled by"
+          ]
 
-constWiki :: [Widget Name]
-constWiki =
-  padLeftRight 13
-    <$> [ padTop (Pad 2) $ txt "For the full list of available commands see the Wiki at:"
-        , txt "https://github.com/swarm-game/swarm/wiki/Commands-Cheat-Sheet"
-        ]
+  commandsTable = mkCmdRow <$> commands
+  mkCmdRow cmd =
+    map
+      (padTop $ Pad 1)
+      [ txt $ syntax $ constInfo cmd
+      , padRight (Pad 2) $ txt $ " : " <> prettyText (inferConst cmd)
+      , listDevices cmd
+      ]
 
-drawConst :: Const -> Widget Name
-drawConst c = hBox [padLeft (Pad $ 13 - T.length constName) (txt constName), txt constSig]
- where
-  constName = syntax . constInfo $ c
-  constSig = " : " <> prettyText (inferConst c)
+  base = gs ^? baseRobot
+  entsByCap = case base of
+    Just r ->
+      M.map NE.toList $
+        entitiesByCapability $
+          (r ^. equippedDevices) `union` (r ^. robotInventory)
+    Nothing -> mempty
 
-descriptionTitle :: Entity -> String
-descriptionTitle e = " " ++ from @Text (e ^. entityName) ++ " "
+  listDevices cmd = vBox $ map drawLabelledEntityName providerDevices
+   where
+    providerDevices =
+      concatMap (flip (M.findWithDefault []) entsByCap) $
+        maybeToList $
+          constCaps cmd
 
 -- | Generate a pop-up widget to display the description of an entity.
 descriptionWidget :: AppState -> Entity -> Widget Name
@@ -713,10 +688,14 @@
   txt2 = txtWrapWith indent2
 
 colorLogs :: LogEntry -> AttrName
-colorLogs e = case e ^. leSaid of
+colorLogs e = case e ^. leSource of
   Said -> robotColor (e ^. leRobotID)
   Logged -> notifAttr
-  ErrorTrace -> redAttr
+  ErrorTrace l -> case l of
+    Debug -> dimAttr
+    Warning -> yellowAttr
+    Error -> redAttr
+    Critical -> redAttr
  where
   -- color each robot message with different color of the world
   robotColor rid = fgCols !! (rid `mod` fgColLen)
@@ -731,10 +710,10 @@
   notificationKey notifLens key name
     | null (s ^. gameState . notifLens . notificationsContent) = Nothing
     | otherwise =
-      let highlight
-            | s ^. gameState . notifLens . notificationsCount > 0 = Alert
-            | otherwise = NoHighlight
-       in Just (highlight, key, name)
+        let highlight
+              | s ^. gameState . notifLens . notificationsCount > 0 = Alert
+              | otherwise = NoHighlight
+         in Just (highlight, key, name)
 
   globalKeyCmds =
     catMaybes
@@ -752,30 +731,41 @@
 -- This excludes the F-key modals that are shown elsewhere.
 drawKeyMenu :: AppState -> Widget Name
 drawKeyMenu s =
-  vLimit 1
-    . hBox
-    . (++ [gameModeWidget])
-    . map (padLeftRight 1 . drawKeyCmd)
-    . (globalKeyCmds ++)
-    . map highlightKeyCmds
-    . keyCmdsFor
-    . focusGetCurrent
-    . view (uiState . uiFocusRing)
-    $ s
+  vLimit 2 $
+    hBox
+      [ vBox
+          [ mkCmdRow globalKeyCmds
+          , padLeft (Pad 2) $ mkCmdRow focusedPanelCmds
+          ]
+      , gameModeWidget
+      ]
  where
+  mkCmdRow = hBox . map drawPaddedCmd
+  drawPaddedCmd = padLeftRight 1 . drawKeyCmd
+  focusedPanelCmds =
+    map highlightKeyCmds $
+      keyCmdsFor $
+        focusGetCurrent $
+          view (uiState . uiFocusRing) s
+
   isReplWorking = s ^. gameState . replWorking
   isPaused = s ^. gameState . paused
   viewingBase = (s ^. gameState . viewCenterRule) == VCRobot 0
   creative = s ^. gameState . creativeMode
   cheat = s ^. uiState . uiCheatMode
-  goal = case s ^. uiState . uiGoal of
-    Just g | g /= [] -> True
-    _ -> False
+  goal = hasAnythingToShow $ s ^. uiState . uiGoal . goalsContent
   showZero = s ^. uiState . uiShowZero
   inventorySort = s ^. uiState . uiInventorySort
+  ctrlMode = s ^. uiState . uiREPL . replControlMode
 
+  renderControlModeSwitch :: ReplControlMode -> T.Text
+  renderControlModeSwitch = \case
+    Piloting -> "REPL"
+    Typing -> "pilot"
+
   gameModeWidget =
-    padLeft Max . padLeftRight 1
+    padLeft Max
+      . padLeftRight 1
       . txt
       . (<> " mode")
       $ case creative of
@@ -796,23 +786,24 @@
     "pop out" | (s ^. uiState . uiMoreInfoBot) || (s ^. uiState . uiMoreInfoTop) -> Alert
     _ -> PanelSpecific
 
-  keyCmdsFor (Just REPLPanel) =
+  keyCmdsFor (Just (FocusablePanel REPLPanel)) =
     [ ("↓↑", "history")
     ]
       ++ [("Enter", "execute") | not isReplWorking]
       ++ [("^c", "cancel") | isReplWorking]
-  keyCmdsFor (Just WorldPanel) =
+      ++ [("M-p", renderControlModeSwitch ctrlMode) | creative]
+  keyCmdsFor (Just (FocusablePanel WorldPanel)) =
     [ ("←↓↑→ / hjkl", "scroll") | creative
     ]
       ++ [("c", "recenter") | not viewingBase]
       ++ [("f", "FPS")]
-  keyCmdsFor (Just RobotPanel) =
+  keyCmdsFor (Just (FocusablePanel RobotPanel)) =
     [ ("Enter", "pop out")
     , ("m", "make")
     , ("0", (if showZero then "hide" else "show") <> " 0")
     , (":/;", T.unwords ["Sort:", renderSortMethod inventorySort])
     ]
-  keyCmdsFor (Just InfoPanel) = []
+  keyCmdsFor (Just (FocusablePanel InfoPanel)) = []
   keyCmdsFor _ = []
 
 data KeyHighlight = NoHighlight | Alert | PanelSpecific
@@ -841,7 +832,7 @@
     . cached WorldCache
     . reportExtent WorldExtent
     -- Set the clickable request after the extent to play nice with the cache
-    . clickable WorldPanel
+    . clickable (FocusablePanel WorldPanel)
     . Widget Fixed Fixed
     $ do
       ctx <- getContext
@@ -850,41 +841,6 @@
           ixs = range (viewingRegion g (fromIntegral w, fromIntegral h))
       render . vBox . map hBox . chunksOf w . map (drawLoc showRobots g) $ ixs
 
--- | Render the 'Display' for a specific location.
-drawLoc :: Bool -> GameState -> W.Coords -> Widget Name
-drawLoc showRobots g = renderDisplay . displayLoc showRobots g
-
--- | Get the 'Display' for a specific location, by combining the
---   'Display's for the terrain, entity, and robots at the location.
-displayLoc :: Bool -> GameState -> W.Coords -> Display
-displayLoc showRobots g coords =
-  sconcat $ terrain NE.:| entity <> robots
- where
-  terrain = terrainMap M.! toEnum (W.lookupTerrain coords (g ^. world))
-  entity = maybeToList (displayForEntity <$> W.lookupEntity coords (g ^. world))
-  robots =
-    if showRobots
-      then map (view robotDisplay) (robotsAtLocation (W.coordsToLoc coords) g)
-      else []
-
-  displayForEntity :: Entity -> Display
-  displayForEntity e = (if known e then id else hidden) (e ^. entityDisplay)
-
-  known e =
-    e `hasProperty` Known
-      || (e ^. entityName) `elem` (g ^. knownEntities)
-      || case hidingMode g of
-        HideAllEntities -> False
-        HideNoEntity -> True
-        HideEntityUnknownTo ro -> ro `robotKnows` e
-
-data HideEntity = HideAllEntities | HideNoEntity | HideEntityUnknownTo Robot
-
-hidingMode :: GameState -> HideEntity
-hidingMode g
-  | g ^. creativeMode = HideNoEntity
-  | otherwise = maybe HideAllEntities HideEntityUnknownTo $ focusedRobot g
-
 ------------------------------------------------------------
 -- Robot inventory panel
 ------------------------------------------------------------
@@ -894,7 +850,7 @@
 drawRobotPanel :: AppState -> Widget Name
 drawRobotPanel s = case (s ^. gameState . to focusedRobot, s ^. uiState . uiInventory) of
   (Just r, Just (_, lst)) ->
-    let V2 x y = r ^. robotLocation
+    let Location x y = r ^. robotLocation
         drawClickableItem pos selb = clickable (InventoryListItem pos) . drawItem (lst ^. BL.listSelectedL) pos selb
      in padBottom Max $
           vBox
@@ -931,7 +887,7 @@
 drawItem _ _ _ (InventoryEntry n e) = drawLabelledEntityName e <+> showCount n
  where
   showCount = padLeft Max . str . show
-drawItem _ _ _ (InstalledEntry e) = drawLabelledEntityName e <+> padLeft Max (str " ")
+drawItem _ _ _ (EquippedEntry e) = drawLabelledEntityName e <+> padLeft Max (str " ")
 
 -- | Draw the name of an entity, labelled with its visual
 --   representation as a cell in the world.
@@ -959,9 +915,9 @@
 explainFocusedItem :: AppState -> Widget Name
 explainFocusedItem s = case focusedItem s of
   Just (InventoryEntry _ e) -> explainEntry s e
-  Just (InstalledEntry e) ->
+  Just (EquippedEntry e) ->
     explainEntry s e
-      -- Special case: installed logger device displays the robot's log.
+      -- Special case: equipped logger device displays the robot's log.
       <=> if e ^. entityName == "logger" then drawRobotLog s else emptyWidget
   _ -> txt " "
 
@@ -998,13 +954,13 @@
 explainRecipes s e
   | null recipes = emptyWidget
   | otherwise =
-    vBox
-      [ padBottom (Pad 1) (hBorderWithLabel (txt "Recipes"))
-      , padLeftRight 2 $
-          hCenter $
-            vBox $
-              map (hLimit widthLimit . padBottom (Pad 1) . drawRecipe (Just e) inv) recipes
-      ]
+      vBox
+        [ padBottom (Pad 1) (hBorderWithLabel (txt "Recipes"))
+        , padLeftRight 2 $
+            hCenter $
+              vBox $
+                map (hLimit widthLimit . padBottom (Pad 1) . drawRecipe (Just e) inv) recipes
+        ]
  where
   recipes = recipesWith s e
 
@@ -1059,11 +1015,11 @@
   connector
     | null reqs = hLimit 5 hBorder
     | otherwise =
-      hBox
-        [ hLimit 2 hBorder
-        , joinableBorder (Edges True False True True)
-        , hLimit 2 hBorder
-        ]
+        hBox
+          [ hLimit 2 hBorder
+          , joinableBorder (Edges True False True True)
+          , hLimit 2 hBorder
+          ]
   inLen = length ins + length times
   outLen = length outs
   times = [(fromIntegral time, timeE) | time /= 1]
@@ -1127,18 +1083,13 @@
 drawRobotLog s =
   vBox
     [ padBottom (Pad 1) (hBorderWithLabel (txt "Log"))
-    , vBox . imap drawEntry $ logEntries
+    , vBox . F.toList . imap drawEntry $ logEntries
     ]
  where
-  logEntries =
-    s
-      & view (gameState . to focusedRobot . _Just . robotLog)
-      & Seq.sort
-      & F.toList
-      & uniq
+  logEntries = s ^. gameState . to focusedRobot . _Just . robotLog
 
   rn = s ^? gameState . to focusedRobot . _Just . robotName
-  n = length logEntries
+  n = Seq.length logEntries
 
   allMe = all ((== rn) . Just . view leRobotName) logEntries
 
@@ -1151,7 +1102,7 @@
 drawLogEntry addName e = withAttr (colorLogs e) . txtWrapWith indent2 $ if addName then name else t
  where
   t = e ^. leText
-  name = "[" <> view leRobotName e <> "] " <> (if e ^. leSaid == Said then "said " <> quote t else t)
+  name = "[" <> view leRobotName e <> "] " <> (if e ^. leSource == Said then "said " <> quote t else t)
 
 ------------------------------------------------------------
 -- REPL panel
@@ -1177,7 +1128,7 @@
   replE =
     renderEditor
       (color . vBox . map txt)
-      (focusGetCurrent focus `elem` [Nothing, Just REPLPanel, Just REPLInput])
+      (focusGetCurrent focus `elem` [Nothing, Just (FocusablePanel REPLPanel), Just REPLInput])
       replEditor
 
 -- | Draw the REPL.
@@ -1196,12 +1147,3 @@
   base = s ^. gameState . robotMap . at 0
   fmt (REPLEntry e) = txt $ "> " <> e
   fmt (REPLOutput t) = txt t
-
-------------------------------------------------------------
--- Utility
-------------------------------------------------------------
-
--- | Display a list of text-wrapped paragraphs with one blank line after
---   each.
-displayParagraphs :: [Text] -> Widget Name
-displayParagraphs = vBox . map (padBottom (Pad 1) . txtWrap)
diff --git a/src/Swarm/TUI/View/Achievement.hs b/src/Swarm/TUI/View/Achievement.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/View/Achievement.hs
@@ -0,0 +1,103 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Swarm.TUI.View.Achievement where
+
+import Brick
+import Brick.Widgets.Border (borderWithLabel)
+import Brick.Widgets.Center (hCenter)
+import Brick.Widgets.List qualified as BL
+import Control.Lens ((^.))
+import Data.Map (Map)
+import Data.Map qualified as M
+import Data.Time.Format (defaultTimeLocale, formatTime)
+import Swarm.TUI.Attr
+import Swarm.TUI.Model
+import Swarm.TUI.Model.Achievement.Attainment
+import Swarm.TUI.Model.Achievement.Definitions
+import Swarm.TUI.Model.Achievement.Description
+import Swarm.TUI.Model.UI
+import Text.Wrap
+
+padAllEvenly :: Int -> Widget Name -> Widget Name
+padAllEvenly x w = padTopBottom x $ padLeftRight (2 * x) w
+
+getCompletionIcon :: Bool -> Widget Name
+getCompletionIcon = \case
+  False -> txt " ○  "
+  True -> withAttr greenAttr $ txt " ●  "
+
+drawAchievementsMenuUI :: AppState -> BL.List Name CategorizedAchievement -> Widget Name
+drawAchievementsMenuUI s l =
+  vBox
+    [ hCenter $ padTopBottom 1 $ str "🏆  Achievements 🏆 "
+    , hCenter $
+        hBox
+          [ hLimitPercent 30 $
+              padAll 2 $
+                BL.renderList (const $ drawAchievementListItem attainedMap) True l
+          , hLimitPercent 50 $
+              maybe emptyWidget (singleAchievementDetails attainedMap . snd) $
+                BL.listSelectedElement l
+          ]
+    ]
+ where
+  attainedMap = s ^. uiState . uiAchievements
+
+drawAchievementListItem ::
+  Map CategorizedAchievement Attainment ->
+  CategorizedAchievement ->
+  Widget Name
+drawAchievementListItem attainedMap x =
+  getCompletionIcon wasAttained <+> titleWidget
+ where
+  wasAttained = M.member x attainedMap
+  titleWidget = txtWrap $ title details
+  details = describe x
+
+singleAchievementDetails ::
+  Map CategorizedAchievement Attainment ->
+  CategorizedAchievement ->
+  Widget Name
+singleAchievementDetails attainedMap x =
+  padRight (Pad 1) $ borderWithLabel titleWidget $ padAllEvenly 1 innerContent
+ where
+  wasAttained = M.member x attainedMap
+
+  renderFlavorTextWidget :: FlavorText -> Widget Name
+  renderFlavorTextWidget (Freeform t) = txtWrap t
+  renderFlavorTextWidget (FTQuotation (Quotation author quoteContent)) =
+    vBox
+      [ txtWrap quoteContent
+      , padLeft Max $
+          padRight (Pad 2) $
+            txtWrapWith (defaultWrapSettings {fillStrategy = FillIndent 2}) $
+              "--" <> author
+      ]
+
+  innerContent =
+    vBox
+      [ maybe emptyWidget (padAllEvenly 2 . renderFlavorTextWidget) $ humorousElaboration details
+      , txtWrap $
+          if wasAttained || not (isObfuscated details)
+            then attainmentProcess details
+            else "???"
+      , case M.lookup x attainedMap of
+          Nothing -> emptyWidget
+          Just attainment ->
+            padTop (Pad 1) $
+              hBox
+                [ txt "Obtained: "
+                , withAttr cyanAttr $
+                    str $
+                      formatTime defaultTimeLocale "%l:%M%P on %b %e, %Y" $
+                        attainment ^. obtainedAt
+                ]
+      , padTop (Pad 1) $
+          hBox
+            [ txt "Effort: "
+            , withAttr boldAttr $ str $ show $ effort details
+            ]
+      ]
+
+  titleWidget = padLeftRight 1 $ txt $ title details
+  details = describe x
diff --git a/src/Swarm/TUI/View/CellDisplay.hs b/src/Swarm/TUI/View/CellDisplay.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/View/CellDisplay.hs
@@ -0,0 +1,59 @@
+module Swarm.TUI.View.CellDisplay where
+
+import Brick
+import Control.Lens
+import Data.List.NonEmpty qualified as NE
+import Data.Map qualified as M
+import Data.Maybe (maybeToList)
+import Data.Semigroup (sconcat)
+import Swarm.Game.Display
+import Swarm.Game.Entity as E
+import Swarm.Game.Robot
+import Swarm.Game.State
+import Swarm.Game.Terrain (terrainMap)
+import Swarm.Game.World qualified as W
+import Swarm.TUI.Model.Name
+
+-- | Render the 'Display' for a specific location.
+drawLoc :: Bool -> GameState -> W.Coords -> Widget Name
+drawLoc showRobots g = renderDisplay . displayLoc showRobots g
+
+displayTerrainCell :: GameState -> W.Coords -> Display
+displayTerrainCell g coords = terrainMap M.! toEnum (W.lookupTerrain coords (g ^. world))
+
+displayEntityCell, displayRobotCell :: GameState -> W.Coords -> [Display]
+displayRobotCell g coords = map (view robotDisplay) (robotsAtLocation (W.coordsToLoc coords) g)
+displayEntityCell g coords = maybeToList (displayForEntity <$> W.lookupEntity coords (g ^. world))
+ where
+  displayForEntity :: Entity -> Display
+  displayForEntity e = (if known e then id else hidden) (e ^. entityDisplay)
+
+  known e =
+    e
+      `hasProperty` Known
+      || (e ^. entityName)
+      `elem` (g ^. knownEntities)
+      || case hidingMode g of
+        HideAllEntities -> False
+        HideNoEntity -> True
+        HideEntityUnknownTo ro -> ro `robotKnows` e
+
+-- | Get the 'Display' for a specific location, by combining the
+--   'Display's for the terrain, entity, and robots at the location.
+displayLoc :: Bool -> GameState -> W.Coords -> Display
+displayLoc showRobots g coords =
+  sconcat $ terrain NE.:| entity <> robots
+ where
+  terrain = displayTerrainCell g coords
+  entity = displayEntityCell g coords
+  robots =
+    if showRobots
+      then displayRobotCell g coords
+      else []
+
+data HideEntity = HideAllEntities | HideNoEntity | HideEntityUnknownTo Robot
+
+hidingMode :: GameState -> HideEntity
+hidingMode g
+  | g ^. creativeMode = HideNoEntity
+  | otherwise = maybe HideAllEntities HideEntityUnknownTo $ focusedRobot g
diff --git a/src/Swarm/TUI/View/CustomStyling.hs b/src/Swarm/TUI/View/CustomStyling.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/View/CustomStyling.hs
@@ -0,0 +1,37 @@
+module Swarm.TUI.View.CustomStyling where
+
+import Brick (AttrName, attrName)
+import Data.Set (toList)
+import Data.Text qualified as T
+import Graphics.Vty.Attributes
+import Numeric (readHex)
+import Swarm.Game.Scenario.Style
+import Swarm.TUI.Attr (worldPrefix)
+
+toStyle :: StyleFlag -> Style
+toStyle = \case
+  Standout -> standout
+  Italic -> italic
+  Strikethrough -> strikethrough
+  Underline -> underline
+  ReverseVideo -> reverseVideo
+  Blink -> blink
+  Dim -> dim
+  Bold -> bold
+
+toAttrColor :: HexColor -> Color
+toAttrColor (HexColor colorText) =
+  case nums of
+    [r, g, b] -> RGBColor r g b
+    _ -> RGBColor 255 255 255
+ where
+  chunks = T.chunksOf 2 $ T.dropWhile (== '#') colorText
+  nums = map (fst . head . readHex . T.unpack) chunks
+
+toAttrPair :: CustomAttr -> (AttrName, Attr)
+toAttrPair ca =
+  (worldPrefix <> attrName (name ca), addStyle $ addFg $ addBg currentAttr)
+ where
+  addFg = maybe id (flip withForeColor . toAttrColor) $ fg ca
+  addBg = maybe id (flip withBackColor . toAttrColor) $ bg ca
+  addStyle = maybe id (flip withStyle . sum . map toStyle . toList) $ style ca
diff --git a/src/Swarm/TUI/View/Util.hs b/src/Swarm/TUI/View/Util.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/TUI/View/Util.hs
@@ -0,0 +1,159 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Swarm.TUI.View.Util where
+
+import Brick hiding (Direction)
+import Brick.Widgets.Dialog
+import Brick.Widgets.List qualified as BL
+import Control.Lens hiding (Const, from)
+import Control.Monad.Reader (withReaderT)
+import Data.List.NonEmpty (NonEmpty (..))
+import Data.Maybe (catMaybes, fromMaybe)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Graphics.Vty qualified as V
+import Swarm.Game.Entity as E
+import Swarm.Game.Scenario (scenarioName)
+import Swarm.Game.ScenarioInfo (scenarioItemName)
+import Swarm.Game.State
+import Swarm.Language.Pretty (prettyText)
+import Swarm.Language.Types (Polytype)
+import Swarm.TUI.Attr
+import Swarm.TUI.Model
+import Swarm.TUI.Model.UI
+import Witch (from, into)
+
+-- | Generate a fresh modal window of the requested type.
+generateModal :: AppState -> ModalType -> Modal
+generateModal s mt = Modal mt (dialog (Just $ str title) buttons (maxModalWindowWidth `min` requiredWidth))
+ where
+  currentScenario = s ^. uiState . scenarioRef
+  currentSeed = s ^. gameState . seed
+  haltingMessage = case s ^. uiState . uiMenu of
+    NoMenu -> Just "Quit"
+    _ -> Nothing
+  descriptionWidth = 100
+  helpWidth = 80
+  (title, buttons, requiredWidth) =
+    case mt of
+      HelpModal -> (" Help ", Nothing, helpWidth)
+      RobotsModal -> ("Robots", Nothing, descriptionWidth)
+      RecipesModal -> ("Available Recipes", Nothing, descriptionWidth)
+      CommandsModal -> ("Available Commands", Nothing, descriptionWidth)
+      MessagesModal -> ("Messages", Nothing, descriptionWidth)
+      WinModal ->
+        let nextMsg = "Next challenge!"
+            stopMsg = fromMaybe "Return to the menu" haltingMessage
+            continueMsg = "Keep playing"
+         in ( ""
+            , Just
+                ( Button NextButton
+                , [ (nextMsg, Button NextButton, Next scene)
+                  | Just scene <- [nextScenario (s ^. uiState . uiMenu)]
+                  ]
+                    ++ [ (stopMsg, Button QuitButton, QuitAction)
+                       , (continueMsg, Button KeepPlayingButton, KeepPlaying)
+                       ]
+                )
+            , sum (map length [nextMsg, stopMsg, continueMsg]) + 32
+            )
+      LoseModal ->
+        let stopMsg = fromMaybe "Return to the menu" haltingMessage
+            continueMsg = "Keep playing"
+            maybeStartOver = do
+              cs <- currentScenario
+              return ("Start over", Button StartOverButton, StartOver currentSeed cs)
+         in ( ""
+            , Just
+                ( Button QuitButton
+                , catMaybes
+                    [ Just (stopMsg, Button QuitButton, QuitAction)
+                    , maybeStartOver
+                    , Just (continueMsg, Button KeepPlayingButton, KeepPlaying)
+                    ]
+                )
+            , sum (map length [stopMsg, continueMsg]) + 32
+            )
+      DescriptionModal e -> (descriptionTitle e, Nothing, descriptionWidth)
+      QuitModal ->
+        let stopMsg = fromMaybe ("Quit to" ++ maybe "" (" " ++) (into @String <$> curMenuName s) ++ " menu") haltingMessage
+            maybeStartOver = do
+              cs <- currentScenario
+              return ("Start over", Button StartOverButton, StartOver currentSeed cs)
+         in ( ""
+            , Just
+                ( Button CancelButton
+                , catMaybes
+                    [ Just ("Keep playing", Button CancelButton, Cancel)
+                    , maybeStartOver
+                    , Just (stopMsg, Button QuitButton, QuitAction)
+                    ]
+                )
+            , T.length (quitMsg (s ^. uiState . uiMenu)) + 4
+            )
+      GoalModal ->
+        let goalModalTitle = case currentScenario of
+              Nothing -> "Goal"
+              Just (scenario, _) -> scenario ^. scenarioName
+         in (" " <> T.unpack goalModalTitle <> " ", Nothing, descriptionWidth)
+      KeepPlayingModal -> ("", Just (Button CancelButton, [("OK", Button CancelButton, Cancel)]), 80)
+
+-- | Render the type of the current REPL input to be shown to the user.
+drawType :: Polytype -> Widget Name
+drawType = withAttr infoAttr . padLeftRight 1 . txt . prettyText
+
+descriptionTitle :: Entity -> String
+descriptionTitle e = " " ++ from @Text (e ^. entityName) ++ " "
+
+-- | Width cap for modal and error message windows
+maxModalWindowWidth :: Int
+maxModalWindowWidth = 500
+
+-- | Get the name of the current New Game menu.
+curMenuName :: AppState -> Maybe Text
+curMenuName s = case s ^. uiState . uiMenu of
+  NewGameMenu (_ :| (parentMenu : _)) ->
+    Just (parentMenu ^. BL.listSelectedElementL . to scenarioItemName)
+  NewGameMenu _ -> Just "Scenarios"
+  _ -> Nothing
+
+quitMsg :: Menu -> Text
+quitMsg m = "Are you sure you want to " <> quitAction <> "? All progress on this scenario will be lost!"
+ where
+  quitAction = case m of
+    NoMenu -> "quit"
+    _ -> "return to the menu"
+
+-- | Display a list of text-wrapped paragraphs with one blank line after
+--   each.
+displayParagraphs :: [Text] -> Widget Name
+displayParagraphs = vBox . map (padBottom (Pad 1) . txtWrap)
+
+withEllipsis :: Text -> Widget Name
+withEllipsis t =
+  Widget Greedy Fixed $ do
+    ctx <- getContext
+    let w = ctx ^. availWidthL
+        ellipsis = T.replicate 3 $ T.singleton '.'
+        tLength = T.length t
+        newText =
+          if tLength > w
+            then T.take (w - T.length ellipsis) t <> ellipsis
+            else t
+    render $ txt newText
+
+-- | Make a widget scrolling if it is bigger than the available
+--   vertical space.  Thanks to jtdaugherty for this code.
+maybeScroll :: (Ord n, Show n) => n -> Widget n -> Widget n
+maybeScroll vpName contents =
+  Widget Greedy Greedy $ do
+    ctx <- getContext
+    result <- withReaderT (availHeightL .~ 10000) (render contents)
+    if V.imageHeight (result ^. imageL) <= ctx ^. availHeightL
+      then return result
+      else
+        render $
+          withVScrollBars OnRight $
+            viewport vpName Vertical $
+              Widget Fixed Fixed $
+                return result
diff --git a/src/Swarm/Util.hs b/src/Swarm/Util.hs
--- a/src/Swarm/Util.hs
+++ b/src/Swarm/Util.hs
@@ -17,17 +17,23 @@
   maxOn,
   maximum0,
   cycleEnum,
+  listEnums,
   uniq,
   getElemsInArea,
   manhattan,
+  binTuples,
 
   -- * Directory utilities
   readFileMay,
   readFileMayT,
-  getSwarmDataPath,
+  getSwarmXdgDataSubdir,
+  getSwarmXdgDataFile,
   getSwarmSavePath,
   getSwarmHistoryPath,
   readAppData,
+  getDataDirSafe,
+  getDataFileNameSafe,
+  dataNotFound,
 
   -- * Text utilities
   isIdentChar,
@@ -37,6 +43,7 @@
   reflow,
   quote,
   squote,
+  bquote,
   commaList,
   indefinite,
   indefiniteQ,
@@ -63,9 +70,6 @@
 
   -- * Utilities for NP-hard approximation
   smallHittingSet,
-  getDataDirSafe,
-  getDataFileNameSafe,
-  dataNotFound,
 ) where
 
 import Control.Algebra (Has)
@@ -76,13 +80,13 @@
 import Control.Lens (ASetter', Lens', LensLike, LensLike', Over, lens, (<>~))
 import Control.Lens.Lens ((&))
 import Control.Monad (forM, unless, when)
-import Data.Aeson (FromJSONKey, ToJSONKey)
 import Data.Bifunctor (first)
 import Data.Char (isAlphaNum)
 import Data.Either.Validation
-import Data.Int (Int64)
+import Data.Int (Int32)
 import Data.List (maximumBy, partition)
 import Data.List.NonEmpty (NonEmpty (..))
+import Data.List.NonEmpty qualified as NE
 import Data.Map (Map)
 import Data.Map qualified as M
 import Data.Maybe (fromMaybe, mapMaybe)
@@ -96,10 +100,10 @@
 import Data.Yaml
 import Language.Haskell.TH
 import Language.Haskell.TH.Syntax (lift)
-import Linear (V2 (V2))
 import NLP.Minimorph.English qualified as MM
 import NLP.Minimorph.Util ((<+>))
 import Paths_swarm (getDataDir)
+import Swarm.Util.Location
 import System.Clock (TimeSpec)
 import System.Directory (
   XdgDirectory (XdgData),
@@ -116,7 +120,7 @@
 
 -- $setup
 -- >>> import qualified Data.Map as M
--- >>> import Linear.V2
+-- >>> import Swarm.Util.Location
 
 infixr 1 ?
 infix 4 %%=, <+=, <%=, <<.=, <>=
@@ -148,6 +152,9 @@
   | e == maxBound = minBound
   | otherwise = succ e
 
+listEnums :: (Enum e, Bounded e) => [e]
+listEnums = [minBound .. maxBound]
+
 -- | Drop repeated elements that are adjacent to each other.
 --
 -- >>> uniq []
@@ -164,21 +171,21 @@
   (x : xs) -> x : uniq (dropWhile (== x) xs)
 
 -- | Manhattan distance between world locations.
-manhattan :: V2 Int64 -> V2 Int64 -> Int64
-manhattan (V2 x1 y1) (V2 x2 y2) = abs (x1 - x2) + abs (y1 - y2)
+manhattan :: Location -> Location -> Int32
+manhattan (Location x1 y1) (Location x2 y2) = abs (x1 - x2) + abs (y1 - y2)
 
 -- | Get elements that are in manhattan distance from location.
 --
--- >>> v2s i = [(v, manhattan (V2 0 0) v) | x <- [-i..i], y <- [-i..i], let v = V2 x y]
+-- >>> v2s i = [(p, manhattan origin p) | x <- [-i..i], y <- [-i..i], let p = Location x y]
 -- >>> v2s 0
--- [(V2 0 0,0)]
--- >>> map (\i -> length (getElemsInArea (V2 0 0) i (M.fromList $ v2s i))) [0..8]
+-- [(P (V2 0 0),0)]
+-- >>> map (\i -> length (getElemsInArea origin i (M.fromList $ v2s i))) [0..8]
 -- [1,5,13,25,41,61,85,113,145]
 --
 -- The last test is the sequence "Centered square numbers":
 -- https://oeis.org/A001844
-getElemsInArea :: V2 Int64 -> Int64 -> Map (V2 Int64) e -> [e]
-getElemsInArea o@(V2 x y) d m = M.elems sm'
+getElemsInArea :: Location -> Int32 -> Map Location e -> [e]
+getElemsInArea o@(Location x y) d m = M.elems sm'
  where
   -- to be more efficient we basically split on first coordinate
   -- (which is logarithmic) and then we have to linearly filter
@@ -194,12 +201,22 @@
   --          ▼▼▼▼
   sm =
     m
-      & M.split (V2 (x - d) (y - 1)) -- A
+      & M.split (Location (x - d) (y - 1)) -- A
       & snd -- A<
-      & M.split (V2 (x + d) (y + 1)) -- B
+      & M.split (Location (x + d) (y + 1)) -- B
       & fst -- B>
   sm' = M.filterWithKey (const . (<= d) . manhattan o) sm
 
+-- | Place the second element of the tuples into bins by
+-- the value of the first element.
+binTuples ::
+  (Foldable t, Ord a) =>
+  t (a, b) ->
+  Map a (NE.NonEmpty b)
+binTuples = foldr f mempty
+ where
+  f = uncurry (M.insertWith (<>)) . fmap pure
+
 ------------------------------------------------------------
 -- Directory stuff
 
@@ -215,23 +232,33 @@
 catchIO :: IO a -> IO (Maybe a)
 catchIO act = (Just <$> act) `catchIOError` (\_ -> return Nothing)
 
+-- | Get subdirectory from swarm data directory.
+--
+-- This will first look in Cabal generated path and then
+-- try a `data` directory in 'XdgData' path.
+--
+-- The idea is that when installing with Cabal/Stack the first
+-- is preferred, but when the players install a binary they
+-- need to extract the `data` archive to the XDG directory.
 getDataDirSafe :: FilePath -> IO (Maybe FilePath)
 getDataDirSafe p = do
-  d <- mySubdir <$> getDataDir
+  d <- (`appDir` p) <$> getDataDir
   de <- doesDirectoryExist d
   if de
     then return $ Just d
     else do
-      xd <- mySubdir . (</> "data") <$> getSwarmDataPath False
+      xd <- (`appDir` p) <$> getSwarmXdgDataSubdir False "data"
       xde <- doesDirectoryExist xd
       return $ if xde then Just xd else Nothing
  where
-  mySubdir d = d `appDir` p
   appDir r = \case
     "" -> r
     "." -> r
     d -> r </> d
 
+-- | Get file from swarm data directory.
+--
+-- See the note in 'getDataDirSafe'.
 getDataFileNameSafe :: FilePath -> IO (Maybe FilePath)
 getDataFileNameSafe name = do
   dir <- getDataDirSafe "."
@@ -242,38 +269,41 @@
       fe <- doesFileExist fp
       return $ if fe then Just fp else Nothing
 
+-- | Get a nice message suggesting to download `data` directory to 'XdgData'.
 dataNotFound :: FilePath -> IO Text
 dataNotFound f = do
-  d <- getSwarmDataPath False
+  d <- getSwarmXdgDataSubdir False ""
   let squotes = squote . T.pack
   return $
     T.unlines
       [ "Could not find the data: " <> squotes f
-      , "Try downloading the Swarm 'data' directory to: " <> squotes d
+      , "Try downloading the Swarm 'data' directory to: " <> squotes (d </> "data")
       ]
 
 -- | Get path to swarm data, optionally creating necessary
---   directories.
-getSwarmDataPath :: Bool -> IO FilePath
-getSwarmDataPath createDirs = do
-  swarmData <- getXdgDirectory XdgData "swarm"
+--   directories. This could fail if user has bad permissions
+--   on his own $HOME or $XDG_DATA_HOME which is unlikely.
+getSwarmXdgDataSubdir :: Bool -> FilePath -> IO FilePath
+getSwarmXdgDataSubdir createDirs subDir = do
+  swarmData <- (</> subDir) <$> getXdgDirectory XdgData "swarm"
   when createDirs (createDirectoryIfMissing True swarmData)
   pure swarmData
 
+getSwarmXdgDataFile :: Bool -> FilePath -> IO FilePath
+getSwarmXdgDataFile createDirs filepath = do
+  let (subDir, file) = splitFileName filepath
+  d <- getSwarmXdgDataSubdir createDirs subDir
+  return $ d </> file
+
 -- | Get path to swarm saves, optionally creating necessary
 --   directories.
 getSwarmSavePath :: Bool -> IO FilePath
-getSwarmSavePath createDirs = do
-  swarmSave <- getXdgDirectory XdgData ("swarm" </> "saves")
-  when createDirs (createDirectoryIfMissing True swarmSave)
-  pure swarmSave
+getSwarmSavePath createDirs = getSwarmXdgDataSubdir createDirs "saves"
 
 -- | Get path to swarm history, optionally creating necessary
---   directories. This could fail if user has bad permissions
---   on his own $HOME or $XDG_DATA_HOME which is unlikely.
+--   directories.
 getSwarmHistoryPath :: Bool -> IO FilePath
-getSwarmHistoryPath createDirs =
-  (</> "history") <$> getSwarmDataPath createDirs
+getSwarmHistoryPath createDirs = getSwarmXdgDataFile createDirs "history"
 
 -- | Read all the .txt files in the data/ directory.
 readAppData :: IO (Map Text Text)
@@ -346,9 +376,9 @@
 singularSubjectVerb :: Text -> Text -> Text
 singularSubjectVerb sub verb
   | verb == "be" = case toUpper sub of
-    "I" -> "I am"
-    "YOU" -> sub <+> "are"
-    _ -> sub <+> "is"
+      "I" -> "I am"
+      "YOU" -> sub <+> "are"
+      _ -> sub <+> "is"
   | otherwise = sub <+> (if is3rdPerson then verb3rd else verb)
  where
   is3rdPerson = toUpper sub `notElem` ["I", "YOU"]
@@ -379,6 +409,10 @@
 quote :: Text -> Text
 quote t = T.concat ["\"", t, "\""]
 
+-- | Surround some text in backticks.
+bquote :: Text -> Text
+bquote t = T.concat ["`", t, "`"]
+
 -- | Make a list of things with commas and the word "and".
 commaList :: [Text] -> Text
 commaList [] = ""
@@ -388,12 +422,6 @@
 
 ------------------------------------------------------------
 -- Some orphan instances
-
-deriving instance ToJSON (V2 Int64)
-deriving instance FromJSON (V2 Int64)
-
-deriving instance FromJSONKey (V2 Int64)
-deriving instance ToJSONKey (V2 Int64)
 
 deriving instance FromJSON TimeSpec
 deriving instance ToJSON TimeSpec
diff --git a/src/Swarm/Util/Location.hs b/src/Swarm/Util/Location.hs
new file mode 100644
--- /dev/null
+++ b/src/Swarm/Util/Location.hs
@@ -0,0 +1,59 @@
+{-# LANGUAGE PatternSynonyms #-}
+{-# OPTIONS_GHC -fno-warn-orphans #-}
+
+-- Orphan JSON instances for Location and Heading
+
+-- |
+-- Module      :  Swarm.Util.Location
+-- Copyright   :  Brent Yorgey
+-- Maintainer  :  byorgey@gmail.com
+--
+-- SPDX-License-Identifier: BSD-3-Clause
+--
+-- Locations and headings.
+module Swarm.Util.Location (Location, pattern Location, Heading, origin, Affine (..)) where
+
+import Data.Aeson (FromJSONKey, ToJSONKey)
+import Data.Int (Int32)
+import Data.Yaml (FromJSON (parseJSON), ToJSON (toJSON))
+import Linear (V2 (..))
+import Linear.Affine (Affine (..), Point (..), origin)
+
+-- | A Location is a pair of (x,y) coordinates, both up to 32 bits.
+--   The positive x-axis points east and the positive y-axis points
+--   north.  These are the coordinates that are shown to players.
+--
+--   See also the 'Coords' type defined in "Swarm.Game.World", which
+--   use a (row, column) format instead, which is more convenient for
+--   internal use.  The "Swarm.Game.World" module also defines
+--   conversions between 'Location' and 'Coords'.
+type Location = Point V2 Int32
+
+-- | A convenient way to pattern-match on 'Location' values.
+pattern Location :: Int32 -> Int32 -> Location
+pattern Location x y = P (V2 x y)
+
+{-# COMPLETE Location #-}
+
+-- | A @Heading@ is a 2D vector, with 32-bit coordinates.
+--
+--   'Location' and 'Heading' are both represented using types from
+--   the @linear@ package, so they can be manipulated using a large
+--   number of operators from that package.  For example:
+--
+--   * Two headings can be added with '^+^'.
+--   * The difference between two 'Location's is a 'Heading' (via '.-.').
+--   * A 'Location' plus a 'Heading' is another 'Location' (via '.^+').
+type Heading = V2 Int32
+
+deriving instance ToJSON (V2 Int32)
+deriving instance FromJSON (V2 Int32)
+
+deriving instance FromJSONKey (V2 Int32)
+deriving instance ToJSONKey (V2 Int32)
+
+instance FromJSON Location where
+  parseJSON = fmap P . parseJSON
+
+instance ToJSON Location where
+  toJSON (P v) = toJSON v
diff --git a/src/Swarm/Web.hs b/src/Swarm/Web.hs
--- a/src/Swarm/Web.hs
+++ b/src/Swarm/Web.hs
@@ -25,41 +25,87 @@
 import Control.Lens ((^.))
 import Control.Monad (void)
 import Control.Monad.IO.Class (liftIO)
+import Data.Foldable (toList)
 import Data.IORef (IORef, readIORef)
 import Data.IntMap qualified as IM
 import Data.Maybe (fromMaybe)
+import Data.Text qualified as T
 import Network.Wai qualified
 import Network.Wai.Handler.Warp qualified as Warp
 import Servant
 import Swarm.Game.Robot
+import Swarm.Game.Scenario.Objective
+import Swarm.Game.Scenario.Objective.Graph
+import Swarm.Game.Scenario.Objective.Presentation.Model
+import Swarm.Game.Scenario.Objective.WinCheck
 import Swarm.Game.State
+import Swarm.TUI.Model
+import Swarm.TUI.Model.UI
 import System.Timeout (timeout)
 
 type SwarmApi =
   "robots" :> Get '[JSON] [Robot]
     :<|> "robot" :> Capture "id" Int :> Get '[JSON] (Maybe Robot)
+    :<|> "goals" :> "prereqs" :> Get '[JSON] [PrereqSatisfaction]
+    :<|> "goals" :> "active" :> Get '[JSON] [Objective]
+    :<|> "goals" :> "graph" :> Get '[JSON] (Maybe GraphInfo)
+    :<|> "goals" :> "uigoal" :> Get '[JSON] GoalTracking
+    :<|> "goals" :> Get '[JSON] WinCondition
+    :<|> "repl" :> "history" :> "full" :> Get '[JSON] [T.Text]
 
-mkApp :: IORef GameState -> Servant.Server SwarmApi
-mkApp gsRef =
+mkApp :: IORef AppState -> Servant.Server SwarmApi
+mkApp appStateRef =
   robotsHandler
     :<|> robotHandler
+    :<|> prereqsHandler
+    :<|> activeGoalsHandler
+    :<|> goalsGraphHandler
+    :<|> uiGoalHandler
+    :<|> goalsHandler
+    :<|> replHandler
  where
   robotsHandler = do
-    g <- liftIO (readIORef gsRef)
-    pure $ IM.elems $ g ^. robotMap
+    appState <- liftIO (readIORef appStateRef)
+    pure $ IM.elems $ appState ^. gameState . robotMap
   robotHandler rid = do
-    g <- liftIO (readIORef gsRef)
-    pure $ IM.lookup rid (g ^. robotMap)
+    appState <- liftIO (readIORef appStateRef)
+    pure $ IM.lookup rid (appState ^. gameState . robotMap)
+  prereqsHandler = do
+    appState <- liftIO (readIORef appStateRef)
+    case appState ^. gameState . winCondition of
+      WinConditions _winState oc -> return $ getSatisfaction oc
+      _ -> return []
+  activeGoalsHandler = do
+    appState <- liftIO (readIORef appStateRef)
+    case appState ^. gameState . winCondition of
+      WinConditions _winState oc -> return $ getActiveObjectives oc
+      _ -> return []
+  goalsGraphHandler = do
+    appState <- liftIO (readIORef appStateRef)
+    return $ case appState ^. gameState . winCondition of
+      WinConditions _winState oc -> Just $ makeGraphInfo oc
+      _ -> Nothing
+  uiGoalHandler = do
+    appState <- liftIO (readIORef appStateRef)
+    return $ appState ^. uiState . uiGoal . goalsContent
+  goalsHandler = do
+    appState <- liftIO (readIORef appStateRef)
+    return $ appState ^. gameState . winCondition
+  replHandler = do
+    appState <- liftIO (readIORef appStateRef)
+    let replHistorySeq = appState ^. uiState . uiREPL . replHistory . replSeq
+        items = [x | REPLEntry x <- toList replHistorySeq]
+    pure items
 
-webMain :: Maybe (MVar (Either String ())) -> Warp.Port -> IORef GameState -> IO ()
-webMain baton port gsRef = catch (Warp.runSettings settings app) handleErr
+webMain :: Maybe (MVar (Either String ())) -> Warp.Port -> IORef AppState -> IO ()
+webMain baton port appStateRef = catch (Warp.runSettings settings app) handleErr
  where
   settings = Warp.setPort port $ onReady Warp.defaultSettings
   onReady = case baton of
     Just mv -> Warp.setBeforeMainLoop $ putMVar mv (Right ())
     Nothing -> id
   app :: Network.Wai.Application
-  app = Servant.serve (Proxy @SwarmApi) (mkApp gsRef)
+  app = Servant.serve (Proxy @SwarmApi) (mkApp appStateRef)
   handleErr :: IOException -> IO ()
   handleErr e = case baton of
     Just mv -> putMVar mv (Left $ displayException e)
@@ -74,13 +120,13 @@
 --   startup doesn't work.  Otherwise, ignore the failure.  In any
 --   case, return a @Maybe Port@ value representing whether a web
 --   server is actually running, and if so, what port it is on.
-startWebThread :: Maybe Warp.Port -> IORef GameState -> IO (Either String Warp.Port)
+startWebThread :: Maybe Warp.Port -> IORef AppState -> IO (Either String Warp.Port)
 -- User explicitly provided port '0': don't run the web server
 startWebThread (Just 0) _ = pure $ Left "The web port has been turned off."
-startWebThread portM gsRef = do
+startWebThread portM appStateRef = do
   baton <- newEmptyMVar
   let port = fromMaybe defaultPort portM
-  void $ forkIO $ webMain (Just baton) port gsRef
+  void $ forkIO $ webMain (Just baton) port appStateRef
   res <- timeout 500_000 (takeMVar baton)
   case (portM, res) of
     -- User requested explicit port but server didn't start: fail
diff --git a/swarm.cabal b/swarm.cabal
--- a/swarm.cabal
+++ b/swarm.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.4
 name:               swarm
-version:            0.2.0.0
+version:            0.3.0.0
 synopsis:           2D resource gathering game with programmable robots
 
 description:        Swarm is a 2D programming and resource gathering
@@ -24,7 +24,7 @@
                     editors/emacs/*.el
                     editors/vscode/syntaxes/*.json
 data-dir:           data/
-data-files:         *.yaml, scenarios/**/*.yaml, scenarios/**/*.txt, scenarios/**/*.sw, *.txt
+data-files:         *.yaml, scenarios/**/*.yaml, scenarios/**/*.txt, scenarios/**/*.sw, *.txt, test/language-snippets/**/*.sw
 
 source-repository head
     type:     git
@@ -81,55 +81,88 @@
 
 library
     import:           stan-config, common, ghc2021-extensions
-    exposed-modules:  Swarm.Language.Context
-                      Swarm.Language.Types
-                      Swarm.Language.Syntax
-                      Swarm.Language.Capability
-                      Swarm.Language.Requirement
-                      Swarm.Language.Parse
-                      Swarm.Language.Parse.QQ
-                      Swarm.Language.Pretty
-                      Swarm.Language.Typecheck
-                      Swarm.Language.Typed
-                      Swarm.Language.Elaborate
-                      Swarm.Language.LSP
-                      Swarm.Language.Pipeline
-                      Swarm.Language.Pipeline.QQ
+    exposed-modules:  Data.BoolExpr
+                      Data.BoolExpr.Simplify
+                      Swarm.App
+                      Swarm.DocGen
                       Swarm.Game.CESK
-                      Swarm.Game.Scenario
-                      Swarm.Game.ScenarioInfo
                       Swarm.Game.Display
                       Swarm.Game.Entity
                       Swarm.Game.Exception
+                      Swarm.Game.Log
                       Swarm.Game.Recipe
                       Swarm.Game.Robot
+                      Swarm.Game.Scenario
+                      Swarm.Game.Scenario.Cell
+                      Swarm.Game.Scenario.Objective.Logic
+                      Swarm.Game.Scenario.Objective.Graph
+                      Swarm.Game.Scenario.Objective.Presentation.Model
+                      Swarm.Game.Scenario.Objective.Presentation.Render
+                      Swarm.Game.Scenario.Objective.Validation
+                      Swarm.Game.Scenario.Objective.WinCheck
+                      Swarm.Game.Scenario.Objective
+                      Swarm.Game.Scenario.RobotLookup
+                      Swarm.Game.Scenario.Style
+                      Swarm.Game.Scenario.WorldDescription
+                      Swarm.Game.ScenarioInfo
                       Swarm.Game.State
                       Swarm.Game.Step
                       Swarm.Game.Terrain
-                      Swarm.Game.Value
                       Swarm.Game.World
                       Swarm.Game.WorldGen
+                      Swarm.Language.Context
+                      Swarm.Language.Capability
+                      Swarm.Language.Elaborate
+                      Swarm.Language.LSP
+                      Swarm.Language.LSP.Hover
+                      Swarm.Language.LSP.VarUsage
+                      Swarm.Language.Module
+                      Swarm.Language.Parse
+                      Swarm.Language.Parse.QQ
+                      Swarm.Language.Pipeline
+                      Swarm.Language.Pipeline.QQ
+                      Swarm.Language.Pretty
+                      Swarm.Language.Requirement
+                      Swarm.Language.Syntax
+                      Swarm.Language.Typecheck
+                      Swarm.Language.Typed
+                      Swarm.Language.Types
+                      Swarm.Language.Value
                       Swarm.TUI.Attr
                       Swarm.TUI.Border
+                      Swarm.TUI.Controller
+                      Swarm.TUI.Controller.Util
+                      Swarm.TUI.View.CustomStyling
+                      Swarm.TUI.Inventory.Sorting
                       Swarm.TUI.List
-                      Swarm.TUI.Panel
                       Swarm.TUI.Model
+                      Swarm.TUI.Model.Achievement.Attainment
+                      Swarm.TUI.Model.Achievement.Definitions
+                      Swarm.TUI.Model.Achievement.Description
+                      Swarm.TUI.Model.Achievement.Persistence
+                      Swarm.TUI.Model.Failure
+                      Swarm.TUI.Model.Menu
+                      Swarm.TUI.Model.Name
+                      Swarm.TUI.Model.Repl
+                      Swarm.TUI.Model.StateUpdate
+                      Swarm.TUI.Model.UI
+                      Swarm.TUI.Panel
                       Swarm.TUI.View
-                      Swarm.TUI.Controller
-                      Swarm.TUI.Inventory.Sorting
-                      Swarm.App
-                      Swarm.Version
-                      Swarm.Web
+                      Swarm.TUI.View.Achievement
+                      Swarm.TUI.View.CellDisplay
+                      Swarm.TUI.View.Util
                       Swarm.Util
-                      Swarm.DocGen
+                      Swarm.Util.Location
                       Swarm.Util.Yaml
+                      Swarm.Version
+                      Swarm.Web
     other-modules:    Paths_swarm
     autogen-modules:  Paths_swarm
 
     build-depends:    base                          >= 4.14 && < 4.18,
                       aeson                         >= 2 && < 2.2,
                       array                         >= 0.5.4 && < 0.6,
-                      brick                         >= 1.0 && < 1.4,
+                      brick                         >= 1.5 && < 1.7,
                       bytestring                    >= 0.10 && < 0.12,
                       clock                         >= 0.8.2 && < 0.9,
                       containers                    >= 0.6.2 && < 0.7,
@@ -147,9 +180,9 @@
                       http-client-tls               >= 0.3 && < 0.4,
                       http-types                    >= 0.12 && < 0.13,
                       lens                          >= 4.19 && < 5.3,
-                      linear                        >= 1.21.6 && < 1.22,
-                      lsp                           >= 1.2 && < 1.7,
-                      megaparsec                    >= 9.0 && < 9.3,
+                      linear                        >= 1.21.6 && < 1.23,
+                      lsp                           >= 1.6 && < 1.7,
+                      megaparsec                    >= 9.0 && < 9.4,
                       minimorph                     >= 0.3 && < 0.4,
                       mtl                           >= 2.2.2 && < 2.3,
                       murmur3                       >= 1.0.4 && < 1.1,
@@ -166,6 +199,7 @@
                       tagged                        >= 0.8 && < 0.9,
                       template-haskell              >= 2.16 && < 2.20,
                       text                          >= 1.2.4 && < 2.1,
+                      text-rope                     >= 0.2 && < 0.3,
                       text-zipper                   >= 0.10 && < 0.13,
                       time                          >= 1.9 && < 1.14,
                       unification-fd                >= 0.11  && < 0.12,
@@ -208,6 +242,8 @@
                       TestNotification
                       TestLanguagePipeline
                       TestPretty
+                      TestBoolExpr
+                      TestLSP
                       TestUtil
 
     build-depends:    tasty                         >= 0.10 && < 1.5,
diff --git a/test/integration/Main.hs b/test/integration/Main.hs
--- a/test/integration/Main.hs
+++ b/test/integration/Main.hs
@@ -5,16 +5,18 @@
 -- | Swarm integration tests
 module Main where
 
-import Control.Lens (Ixed (ix), to, use, view, (&), (.~), (<&>), (^.), (^?!))
-import Control.Monad (filterM, forM_, unless, void, when)
+import Control.Lens (Ixed (ix), to, use, view, (&), (.~), (<&>), (<>~), (^.), (^..), (^?!))
+import Control.Monad (filterM, forM_, unless, when)
 import Control.Monad.State (StateT (runStateT), gets)
 import Control.Monad.Trans.Except (runExceptT)
 import Data.Char (isSpace)
 import Data.Containers.ListUtils (nubOrd)
 import Data.Foldable (Foldable (toList), find)
 import Data.IntSet qualified as IS
+import Data.List (partition)
 import Data.Map qualified as M
 import Data.Maybe (isJust)
+import Data.Sequence (Seq)
 import Data.Text (Text)
 import Data.Text qualified as T
 import Data.Text.IO qualified as T
@@ -23,11 +25,12 @@
 import Swarm.DocGen qualified as DocGen
 import Swarm.Game.CESK (emptyStore, initMachine)
 import Swarm.Game.Entity (EntityMap, loadEntities)
-import Swarm.Game.Robot (leText, machine, robotLog, waitingUntil)
+import Swarm.Game.Robot (LogEntry, defReqs, leText, machine, robotContext, robotLog, waitingUntil)
 import Swarm.Game.Scenario (Scenario)
 import Swarm.Game.State (
   GameState,
-  WinCondition (Won),
+  WinCondition (WinConditions),
+  WinStatus (Won),
   activeRobots,
   baseRobot,
   initGameStateForScenario,
@@ -40,21 +43,25 @@
  )
 import Swarm.Game.Step (gameTick)
 import Swarm.Language.Context qualified as Ctx
-import Swarm.Language.Pipeline (processTerm)
+import Swarm.Language.Pipeline (ProcessedTerm (..), processTerm)
 import Swarm.Util.Yaml (decodeFileEitherE)
 import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
 import System.Environment (getEnvironment)
+import System.FilePath (splitDirectories)
 import System.FilePath.Posix (takeExtension, (</>))
 import System.Timeout (timeout)
 import Test.Tasty (TestTree, defaultMain, testGroup)
-import Test.Tasty.ExpectedFailure (expectFailBecause)
 import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase)
 import Witch (into)
 
+isUnparseableTest :: (FilePath, String) -> Bool
+isUnparseableTest (fp, _) = "_Validation" `elem` splitDirectories fp
+
 main :: IO ()
 main = do
   examplePaths <- acquire "example" "sw"
   scenarioPaths <- acquire "data/scenarios" "yaml"
+  let (unparseableScenarios, parseableScenarios) = partition isUnparseableTest scenarioPaths
   scenarioPrograms <- acquire "data/scenarios" "sw"
   ci <- any (("CI" ==) . fst) <$> getEnvironment
   entities <- loadEntities
@@ -66,7 +73,8 @@
           "Tests"
           [ exampleTests examplePaths
           , exampleTests scenarioPrograms
-          , scenarioTests em scenarioPaths
+          , scenarioParseTests em parseableScenarios
+          , scenarioParseInvalidTests em unparseableScenarios
           , testScenarioSolution ci em
           , testEditorFiles
           ]
@@ -81,19 +89,34 @@
  where
   value = processTerm $ into @Text fileContent
 
-scenarioTests :: EntityMap -> [(FilePath, String)] -> TestTree
-scenarioTests em inputs = testGroup "Test scenarios" (map (scenarioTest em) inputs)
+scenarioParseTests :: EntityMap -> [(FilePath, String)] -> TestTree
+scenarioParseTests em inputs =
+  testGroup
+    "Test scenarios parse"
+    (map (scenarioTest Parsed em) inputs)
 
-scenarioTest :: EntityMap -> (FilePath, String) -> TestTree
-scenarioTest em (path, _) =
-  testCase ("parse scenario " ++ show path) (void $ getScenario em path)
+scenarioParseInvalidTests :: EntityMap -> [(FilePath, String)] -> TestTree
+scenarioParseInvalidTests em inputs =
+  testGroup
+    "Test invalid scenarios fail to parse"
+    (map (scenarioTest Failed em) inputs)
 
-getScenario :: EntityMap -> FilePath -> IO Scenario
-getScenario em p = do
+data ParseResult = Parsed | Failed
+
+scenarioTest :: ParseResult -> EntityMap -> (FilePath, String) -> TestTree
+scenarioTest expRes em (path, _) =
+  testCase ("parse scenario " ++ show path) (getScenario expRes em path)
+
+getScenario :: ParseResult -> EntityMap -> FilePath -> IO ()
+getScenario expRes em p = do
   res <- decodeFileEitherE em p :: IO (Either ParseException Scenario)
-  case res of
-    Left err -> assertFailure (prettyPrintParseException err)
-    Right s -> return s
+  case expRes of
+    Parsed -> case res of
+      Left err -> assertFailure (prettyPrintParseException err)
+      Right _s -> return ()
+    Failed -> case res of
+      Left _err -> return ()
+      Right _s -> assertFailure "Unexpectedly parsed invalid scenario!"
 
 acquire :: FilePath -> String -> IO [(FilePath, String)]
 acquire dir ext = do
@@ -124,6 +147,8 @@
   sec :: Int
   sec = 10 ^ (6 :: Int)
 
+data ShouldCheckBadErrors = CheckForBadErrors | AllowBadErrors deriving (Eq, Show)
+
 testScenarioSolution :: Bool -> EntityMap -> TestTree
 testScenarioSolution _ci _em =
   testGroup
@@ -137,10 +162,10 @@
         , testSolution Default "Tutorials/place"
         , testSolution Default "Tutorials/types"
         , testSolution Default "Tutorials/type-errors"
-        , testSolution Default "Tutorials/bind"
-        , testSolution Default "Tutorials/install"
+        , testSolution Default "Tutorials/equip"
         , testSolution Default "Tutorials/build"
-        , testSolution' Default "Tutorials/crash" $ \g -> do
+        , testSolution Default "Tutorials/bind2"
+        , testSolution' Default "Tutorials/crash" CheckForBadErrors $ \g -> do
             let rs = toList $ g ^. robotMap
             let hints = any (T.isInfixOf "you will win" . view leText) . toList . view robotLog
             let win = isJust $ find hints rs
@@ -149,7 +174,7 @@
         , testSolution Default "Tutorials/def"
         , testSolution Default "Tutorials/lambda"
         , testSolution Default "Tutorials/require"
-        , testSolution Default "Tutorials/requireinv"
+        , testSolution (Sec 3) "Tutorials/requireinv"
         , testSolution Default "Tutorials/conditionals"
         , testSolution (Sec 5) "Tutorials/farming"
         ]
@@ -158,7 +183,10 @@
         [ testSolution Default "Challenges/chess_horse"
         , testSolution Default "Challenges/teleport"
         , testSolution (Sec 5) "Challenges/2048"
+        , testSolution (Sec 3) "Challenges/word-search"
+        , testSolution (Sec 3) "Challenges/ice-cream"
         , testSolution (Sec 10) "Challenges/hanoi"
+        , testSolution Default "Challenges/friend"
         , testGroup
             "Mazes"
             [ testSolution Default "Challenges/Mazes/easy_cave_maze"
@@ -166,19 +194,22 @@
             , testSolution Default "Challenges/Mazes/invisible_maze"
             , testSolution Default "Challenges/Mazes/loopy_maze"
             ]
+        , testGroup
+            "Ranching"
+            [ testSolution (Sec 30) "Challenges/Ranching/gated-paddock"
+            ]
         ]
     , testGroup
         "Regression tests"
-        [ expectFailBecause "Awaiting fix (#394)" $
-            testSolution Default "Testing/394-build-drill"
+        [ testSolution Default "Testing/394-build-drill"
         , testSolution Default "Testing/373-drill"
         , testSolution Default "Testing/428-drowning-destroy"
-        , testSolution' Default "Testing/475-wait-one" $ \g -> do
+        , testSolution' Default "Testing/475-wait-one" CheckForBadErrors $ \g -> do
             let t = g ^. ticks
                 r1Waits = g ^?! robotMap . ix 1 . to waitingUntil
                 active = IS.member 1 $ g ^. activeRobots
                 waiting = elem 1 . concat . M.elems $ g ^. waitingRobots
-            assertBool "The game should only take one tick" $ t == 1
+            assertBool "The game should only take two ticks" $ t == 2
             assertBool "Robot 1 should have waiting machine" $ isJust r1Waits
             assertBool "Robot 1 should be still active" active
             assertBool "Robot 1 should not be in waiting set" $ not waiting
@@ -186,13 +217,18 @@
         , testSolution Default "Testing/504-teleport-self"
         , testSolution Default "Testing/508-capability-subset"
         , testGroup
+            "Possession criteria (#858)"
+            [ testSolution Default "Testing/858-inventory/858-possession-objective"
+            , testSolution Default "Testing/858-inventory/858-counting-objective"
+            , testSolution Default "Testing/858-inventory/858-nonpossession-objective"
+            ]
+        , testGroup
             "Require (#201)"
             [ testSolution Default "Testing/201-require/201-require-device"
             , testSolution Default "Testing/201-require/201-require-device-creative"
             , testSolution Default "Testing/201-require/201-require-device-creative1"
             , testSolution Default "Testing/201-require/201-require-entities"
-            , expectFailBecause "Awaiting fix (#540)" $
-                testSolution Default "Testing/201-require/201-require-entities-def"
+            , testSolution Default "Testing/201-require/201-require-entities-def"
             , testSolution Default "Testing/201-require/533-reprogram-simple"
             , testSolution Default "Testing/201-require/533-reprogram"
             ]
@@ -206,6 +242,21 @@
         , testSolution Default "Testing/699-movement-fail/699-move-liquid"
         , testSolution Default "Testing/699-movement-fail/699-teleport-blocked"
         , testSolution Default "Testing/710-multi-robot"
+        , testSolution Default "Testing/920-meet"
+        , testSolution Default "Testing/955-heading"
+        , testSolution' Default "Testing/397-wrong-missing" CheckForBadErrors $ \g -> do
+            let msgs =
+                  (g ^. messageQueue . to seqToTexts)
+                    <> (g ^.. robotMap . traverse . robotLog . to seqToTexts . traverse)
+
+            assertBool "Should be some messages" (not (null msgs))
+            assertBool "Error messages should not mention treads" $
+              not (any ("treads" `T.isInfixOf`) msgs)
+            assertBool "Error message should mention GPS receiver" $
+              any ("GPS receiver" `T.isInfixOf`) msgs
+        , testSolution Default "Testing/961-custom-capabilities"
+        , testSolution Default "Testing/956-GPS"
+        , testSolution Default "Testing/958-isempty"
         ]
     ]
  where
@@ -213,30 +264,38 @@
   -- expectFailIf b = if b then expectFailBecause else (\_ x -> x)
 
   testSolution :: Time -> FilePath -> TestTree
-  testSolution s p = testSolution' s p (const $ pure ())
+  testSolution s p = testSolution' s p CheckForBadErrors (const $ pure ())
 
-  testSolution' :: Time -> FilePath -> (GameState -> Assertion) -> TestTree
-  testSolution' s p verify = testCase p $ do
-    Right gs <- runExceptT $ initGameStateForScenario p Nothing Nothing
-    case gs ^. winSolution of
-      Nothing -> assertFailure "No solution to test!"
-      Just sol -> do
-        let gs' = gs & baseRobot . machine .~ initMachine sol Ctx.empty emptyStore
-        m <- timeout (time s) (snd <$> runStateT playUntilWin gs')
-        case m of
-          Nothing -> assertFailure "Timed out - this likely means that the solution did not work."
-          Just g -> do
-            -- When debugging, try logging all robot messages.
-            -- printAllLogs
-            noBadErrors g
-            verify g
+  testSolution' :: Time -> FilePath -> ShouldCheckBadErrors -> (GameState -> Assertion) -> TestTree
+  testSolution' s p shouldCheckBadErrors verify = testCase p $ do
+    out <- runExceptT $ initGameStateForScenario p Nothing Nothing
+    case out of
+      Left x -> assertFailure $ unwords ["Failure in initGameStateForScenario:", T.unpack x]
+      Right gs -> case gs ^. winSolution of
+        Nothing -> assertFailure "No solution to test!"
+        Just sol@(ProcessedTerm _ _ reqCtx) -> do
+          let gs' =
+                gs
+                  -- See #827 for an explanation of why it's important to add to
+                  -- the robotContext defReqs here (and also why this will,
+                  -- hopefully, eventually, go away).
+                  & baseRobot . robotContext . defReqs <>~ reqCtx
+                  & baseRobot . machine .~ initMachine sol Ctx.empty emptyStore
+          m <- timeout (time s) (snd <$> runStateT playUntilWin gs')
+          case m of
+            Nothing -> assertFailure "Timed out - this likely means that the solution did not work."
+            Just g -> do
+              -- When debugging, try logging all robot messages.
+              -- printAllLogs
+              when (shouldCheckBadErrors == CheckForBadErrors) $ noBadErrors g
+              verify g
 
   playUntilWin :: StateT GameState IO ()
   playUntilWin = do
     w <- use winCondition
     b <- gets badErrorsInLogs
     when (null b) $ case w of
-      Won _ -> return ()
+      WinConditions (Won _) _ -> return ()
       _ -> gameTick >> playUntilWin
 
 noBadErrors :: GameState -> Assertion
@@ -251,8 +310,10 @@
     (g ^. robotMap)
     <> filter isBad (seqToTexts $ g ^. messageQueue)
  where
-  seqToTexts = map (view leText) . toList
   isBad m = "Fatal error:" `T.isInfixOf` m || "swarm/issues" `T.isInfixOf` m
+
+seqToTexts :: Seq LogEntry -> [Text]
+seqToTexts = map (view leText) . toList
 
 printAllLogs :: GameState -> IO ()
 printAllLogs g =
diff --git a/test/unit/Main.hs b/test/unit/Main.hs
--- a/test/unit/Main.hs
+++ b/test/unit/Main.hs
@@ -20,8 +20,10 @@
   testProperty,
   (==>),
  )
+import TestBoolExpr (testBoolExpr)
 import TestEval (testEval)
 import TestInventory (testInventory)
+import TestLSP (testLSP)
 import TestLanguagePipeline (testLanguagePipeline)
 import TestModel (testModel)
 import TestNotification (testNotification)
@@ -41,11 +43,13 @@
     "Tests"
     [ testLanguagePipeline
     , testPrettyConst
+    , testBoolExpr
     , testEval g
     , testModel
     , testInventory
     , testNotification g
     , testMisc
+    , testLSP
     ]
 
 testMisc :: TestTree
diff --git a/test/unit/TestBoolExpr.hs b/test/unit/TestBoolExpr.hs
new file mode 100644
--- /dev/null
+++ b/test/unit/TestBoolExpr.hs
@@ -0,0 +1,103 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Boolean expression unit tests
+module TestBoolExpr where
+
+import Data.BoolExpr qualified as BE
+import Data.BoolExpr.Simplify qualified as Simplify
+import Data.List.NonEmpty (NonEmpty ((:|)))
+import Data.Set qualified as Set
+import Swarm.Game.Scenario.Objective.Logic
+import Swarm.Game.Scenario.Objective.WinCheck qualified as WC
+import Test.Tasty
+import Test.Tasty.HUnit
+
+testBoolExpr :: TestTree
+testBoolExpr =
+  testGroup
+    "Boolean evaluation"
+    [ testGroup
+        "Expression simplification"
+        [ testGroup
+            "Return true if the expression can be simplified to False"
+            [ testGroup
+                "Effect of constant literals"
+                [ testCase
+                    "False input via single literal"
+                    $ expectTrue BE.BFalse
+                , testCase
+                    "True input via composed literals"
+                    $ expectFalse BE.BTrue
+                , testCase
+                    "False input via composed literals"
+                    $ expectTrue
+                    $ BE.BOr BE.BFalse BE.BFalse
+                , testCase
+                    "True input via composed literals"
+                    $ expectFalse
+                    $ BE.BOr BE.BFalse BE.BTrue
+                , testCase
+                    "Constant OR'd with False"
+                    $ expectFalse
+                    $ BE.BOr BE.BFalse (BE.BConst (BE.Positive "foo"))
+                , testCase
+                    "Constant OR'd with True"
+                    $ expectFalse
+                    $ BE.BOr (BE.BConst (BE.Positive "foo")) BE.BTrue
+                , testCase
+                    "Constant AND'd with False"
+                    $ expectTrue
+                    $ BE.BAnd BE.BFalse (BE.BConst (BE.Positive "foo"))
+                , testCase
+                    "Constant AND'd with True"
+                    $ expectFalse
+                    $ BE.BAnd (BE.BConst (BE.Positive "foo")) BE.BTrue
+                , testCase
+                    "Nested Constants AND'd with False within OR"
+                    $ expectTrue
+                    $ BE.BOr
+                      (BE.BAnd BE.BFalse (BE.BConst (BE.Positive "foo")))
+                      (BE.BAnd (BE.BConst (BE.Positive "bar")) BE.BFalse)
+                , testCase
+                    "Deeply nested Constants AND'd with False within OR with multiple negations"
+                    $ expectTrue
+                    $ BE.BOr
+                      (BE.BAnd (BE.BNot BE.BTrue) (BE.BNot (BE.BNot (BE.BNot (BE.BConst (BE.Positive "foo"))))))
+                      (BE.BAnd (BE.BConst (BE.Positive "bar")) (BE.BNot (BE.BNot BE.BFalse)))
+                ]
+            , testGroup
+                "Effect of contradicting named constants"
+                [ testCase
+                    "via NOT operator"
+                    $ expectTrue
+                    $ BE.BAnd (BE.BNot (BE.BConst (BE.Positive "foo"))) (BE.BConst (BE.Positive "foo"))
+                , testCase
+                    "via signedness"
+                    $ expectTrue
+                    $ BE.BAnd (BE.BConst (BE.Positive "foo")) (BE.BConst (BE.Negative "foo"))
+                ]
+            ]
+        ]
+    , testGroup
+        "Prerequisite expressions"
+        [ testCase
+            "A negated goal is completed"
+            $ assertBool "Should have returned true"
+            $ WC.isUnwinnablePrereq (Set.singleton "b") demoPrereqs
+        , testCase
+            "A non-negated goal is completed"
+            $ assertBool "Should have returned false"
+            $ not
+            $ WC.isUnwinnablePrereq (Set.singleton "c") demoPrereqs
+        ]
+    ]
+ where
+  expectTrue, expectFalse :: BE.BoolExpr String -> Assertion
+  expectTrue = assertBool "Should have returned true" . Simplify.cannotBeTrue
+  expectFalse = assertBool "Should have returned false" . not . Simplify.cannotBeTrue
+
+  demoPrereqs :: Prerequisite ObjectiveLabel
+  demoPrereqs =
+    And $
+      Id "a"
+        :| [Not (Id "b"), Id "c"]
diff --git a/test/unit/TestEval.hs b/test/unit/TestEval.hs
--- a/test/unit/TestEval.hs
+++ b/test/unit/TestEval.hs
@@ -5,10 +5,11 @@
 module TestEval where
 
 import Control.Lens ((^.), _3)
+import Data.Char (ord)
 import Data.Text (Text)
 import Data.Text qualified as T
 import Swarm.Game.State
-import Swarm.Game.Value
+import Swarm.Language.Value
 import Test.Tasty
 import Test.Tasty.HUnit
 import Test.Tasty.QuickCheck
@@ -238,6 +239,27 @@
                  in T.concat ["(", t1, ",", t2, ") == split (chars ", t1, ") (", t1, " ++ ", t2, ")"]
                       `evaluatesToP` VBool True
             )
+        , testProperty
+            "charAt"
+            $ \i (NonEmpty s) ->
+              let i' = i `mod` length s
+               in T.concat ["charAt ", from @String (show i'), " ", tquote s]
+                    `evaluatesToP` VInt (fromIntegral (ord (s !! i')))
+        , testCase
+            "toChar 97"
+            ("toChar 97 == \"a\"" `evaluatesTo` VBool True)
+        , testProperty
+            "chars/toChar"
+            ( \(NonNegative (i :: Integer)) ->
+                T.concat ["chars (toChar ", from @String (show i), ")"]
+                  `evaluatesToP` VInt 1
+            )
+        , testProperty
+            "charAt/toChar"
+            ( \(NonNegative i) ->
+                T.concat ["charAt 0 (toChar ", from @String (show i), ")"]
+                  `evaluatesToP` VInt i
+            )
         ]
     ]
  where
@@ -251,7 +273,7 @@
       Left err ->
         p err
           @? "Expected predicate did not hold on error message "
-          ++ from @Text @String err
+            ++ from @Text @String err
 
   evaluatesTo :: Text -> Value -> Assertion
   evaluatesTo tm val = do
diff --git a/test/unit/TestLSP.hs b/test/unit/TestLSP.hs
new file mode 100644
--- /dev/null
+++ b/test/unit/TestLSP.hs
@@ -0,0 +1,88 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | LSP unit tests
+module TestLSP (testLSP) where
+
+import Data.Text (Text)
+import Data.Text.IO qualified as TIO
+import Swarm.Language.LSP.VarUsage qualified as VU
+import Swarm.Language.Parse (readTerm')
+import Swarm.Language.Syntax qualified as S
+import System.FilePath ((</>))
+import Test.Tasty
+import Test.Tasty.HUnit
+
+baseTestPath :: FilePath
+baseTestPath = "data/test/language-snippets/warnings/unused-vars"
+
+data UnusedVar = UnusedVar S.Var VU.BindingType
+  deriving (Eq, Show)
+
+simplifyWarning :: VU.VarUsage -> UnusedVar
+simplifyWarning (VU.VarUsage (S.LV _ v) scope) = UnusedVar v scope
+
+testLSP :: TestTree
+testLSP =
+  testGroup
+    "Unused variable warnings"
+    [ testCase "outer lambda" $
+        checkFile "multiple-lambda-first-unused.sw" $
+          pure $
+            UnusedVar "x" VU.Lambda
+    , testCase "inner lambda" $
+        checkFile "multiple-lambda-second-unused.sw" $
+          pure $
+            UnusedVar "y" VU.Lambda
+    , testCase "shadowed variable name" $
+        checkFile "shadowed-variable-lambda-unused.sw" $
+          pure $
+            UnusedVar "x" VU.Lambda
+    , testCase "outer let" $
+        checkFile "multiple-let-first-unused.sw" $
+          pure $
+            UnusedVar "x" VU.Let
+    , testCase "outer let" $
+        checkFile "multiple-let-second-unused.sw" $
+          pure $
+            UnusedVar "y" VU.Let
+    , testCase "multiple unused let" $
+        checkFile
+          "multiple-let-all-unused.sw"
+          [UnusedVar "x" VU.Let, UnusedVar "y" VU.Let]
+    , testCase "shadowed let without usage" $
+        checkFile
+          "shadowed-variable-let-unused.sw"
+          [UnusedVar "x" VU.Let]
+    , testCase "shadowed let with intermediate usage" $
+        checkFile
+          "shadowed-variable-let-intermediate-use.sw"
+          []
+    , testCase "recursive let" $
+        checkFile
+          "recursive-let.sw"
+          [UnusedVar "fac" VU.Let]
+    , testCase "single unused bind" $
+        checkFile
+          "single-bind-unused.sw"
+          [UnusedVar "x" VU.Bind]
+    , testCase "single used bind" $
+        checkFile
+          "single-bind-used.sw"
+          []
+    ]
+ where
+  checkFile :: FilePath -> [UnusedVar] -> IO ()
+  checkFile filename expectedWarnings = do
+    content <- TIO.readFile fullPath
+    let actualWarnings = getWarnings content
+    assertEqual "failed" expectedWarnings actualWarnings
+   where
+    fullPath = baseTestPath </> filename
+
+  getWarnings :: Text -> [UnusedVar]
+  getWarnings content =
+    case readTerm' content of
+      Right (Just term) -> map simplifyWarning problems
+       where
+        VU.Usage _ problems = VU.getUsage mempty term
+      _ -> []
diff --git a/test/unit/TestLanguagePipeline.hs b/test/unit/TestLanguagePipeline.hs
--- a/test/unit/TestLanguagePipeline.hs
+++ b/test/unit/TestLanguagePipeline.hs
@@ -1,15 +1,24 @@
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE QuasiQuotes #-}
+{-# LANGUAGE TemplateHaskell #-}
 
 -- | Swarm unit tests
 module TestLanguagePipeline where
 
+import Control.Arrow ((&&&))
+import Control.Lens (toListOf)
+import Control.Lens.Plated (universe)
 import Data.Aeson (eitherDecode, encode)
 import Data.Either
 import Data.Maybe
 import Data.Text (Text)
 import Data.Text qualified as T
 import Data.Text.Encoding qualified as T
-import Swarm.Language.Pipeline (processTerm)
+import Swarm.Language.Module (Module (..))
+import Swarm.Language.Parse.QQ (tyQ)
+import Swarm.Language.Pipeline (ProcessedTerm (..), processTerm)
+import Swarm.Language.Pipeline.QQ (tmQ)
+import Swarm.Language.Syntax
 import Swarm.Language.Typecheck (isSimpleUType)
 import Swarm.Language.Types
 import Test.Tasty
@@ -245,11 +254,38 @@
             "void - valid type signature"
             (valid "def f : void -> a = \\x. undefined end")
         ]
+    , testGroup
+        "type annotations"
+        [ testCase
+            "annotate 1 + 1"
+            ( assertEqual
+                "type annotations"
+                (toListOf traverse (getSyntax [tmQ| 1 + 1 |]))
+                [[tyQ| int -> int -> int|], [tyQ|int|], [tyQ|int -> int|], [tyQ|int|], [tyQ|int|]]
+            )
+        , testCase
+            "get all annotated variable types"
+            ( let s =
+                    getSyntax
+                      [tmQ| def f : (int -> int) -> int -> text = \g. \x. format (g x) end |]
+
+                  isVar (TVar {}) = True
+                  isVar _ = False
+                  getVars = map (_sTerm &&& _sType) . filter (isVar . _sTerm) . universe
+               in assertEqual
+                    "variable types"
+                    (getVars s)
+                    ( [ (TVar "g", [tyQ| int -> int |])
+                      , (TVar "x", [tyQ| int |])
+                      ]
+                    )
+            )
+        ]
     ]
  where
   valid = flip process ""
 
-  roundTrip txt = assertEqual "rountrip" term (decodeThrow $ encode term)
+  roundTrip txt = assertEqual "roundtrip" term (decodeThrow $ encode term)
    where
     decodeThrow v = case eitherDecode v of
       Left e -> error $ "Decoding of " <> from (T.decodeUtf8 (from v)) <> " failed with: " <> from e
@@ -264,3 +300,6 @@
     Right _
       | expect == "" -> pure ()
       | otherwise -> error "Unexpected success"
+
+  getSyntax :: ProcessedTerm -> Syntax' Polytype
+  getSyntax (ProcessedTerm (Module s _) _ _) = s
diff --git a/test/unit/TestNotification.hs b/test/unit/TestNotification.hs
--- a/test/unit/TestNotification.hs
+++ b/test/unit/TestNotification.hs
@@ -34,7 +34,7 @@
         assertEqual "There should be two messages in queue" [0, 1] (view leTime <$> gs' ^. messageNotifications . notificationsContent)
         assertNew (gs' & lastSeenMessageTime .~ 0) 1 "message" messageNotifications
     , testCase "new message after log" $ do
-        gs' <- goodPlay "create \"logger\"; install self \"logger\"; log \"Hello world!\""
+        gs' <- goodPlay "create \"logger\"; equip \"logger\"; log \"Hello world!\""
         let r = gs' ^?! robotMap . ix (-1)
         assertBool "There should be one log entry in robots log" (length (r ^. robotLog) == 1)
         assertEqual "The hypothetical robot should be in focus" (Just (r ^. robotID)) (view robotID <$> focusedRobot gs')
diff --git a/test/unit/TestPretty.hs b/test/unit/TestPretty.hs
--- a/test/unit/TestPretty.hs
+++ b/test/unit/TestPretty.hs
@@ -36,17 +36,22 @@
     , testCase
         "operators #8 - double unary negation"
         ( equalPretty "-(-1)" $
-            TApp (TConst Neg) $ TApp (TConst Neg) (TInt 1)
+            TApp (TConst Neg) $
+              TApp (TConst Neg) (TInt 1)
         )
     , testCase
         "operators #8 - unary negation with strongly fixing binary operator"
         ( equalPretty "-1 ^ (-2)" $
-            TApp (TConst Neg) $ mkOp' Exp (TInt 1) $ TApp (TConst Neg) (TInt 2)
+            TApp (TConst Neg) $
+              mkOp' Exp (TInt 1) $
+                TApp (TConst Neg) (TInt 2)
         )
     , testCase
         "operators #8 - unary negation with weakly fixing binary operator"
         ( equalPretty "-(1 + -2)" $
-            TApp (TConst Neg) $ mkOp' Add (TInt 1) $ TApp (TConst Neg) (TInt 2)
+            TApp (TConst Neg) $
+              mkOp' Add (TInt 1) $
+                TApp (TConst Neg) (TInt 2)
         )
     , testCase
         "operators #8 - simple infix operator"
diff --git a/test/unit/TestUtil.hs b/test/unit/TestUtil.hs
--- a/test/unit/TestUtil.hs
+++ b/test/unit/TestUtil.hs
@@ -14,9 +14,9 @@
 import Swarm.Game.Robot
 import Swarm.Game.State
 import Swarm.Game.Step (gameTick, hypotheticalRobot, stepCESK)
-import Swarm.Game.Value
 import Swarm.Language.Context
 import Swarm.Language.Pipeline (ProcessedTerm (..), processTerm)
+import Swarm.Language.Value
 
 eval :: GameState -> Text -> IO (GameState, Robot, Either Text (Value, Int))
 eval g = either (return . (g,hypotheticalRobot undefined 0,) . Left) (evalPT g) . processTerm1
