Eloquent JS Project Robot Explained — Part 3
This is part 3 of a three-part tutorial series, going into depth to explain the robot project from Eloquent Javascript chapter 7.
In part 1 we built and ran a simple world with a simple robot. In part 2 we improved the code and introduced a smarter robot. In this final part, we’ll move the code ever closer to the book’s version by converting the village state to a class. We’ll also write code to procedurally generate a random world instead of a hard-coded one.
You can access the other parts here:
Eloquent JS Project Robot Explained — Part 1
Eloquent JS Project Robot Explained — Part 2
Converting ‘move’ to a Method
What do you think of the relationship between the move
function and villageState
? Wouldn’t it be convenient to relocate the move
function to be a method inside villageState
? Let’s do that.
Challenge
- Relocate the
move
function to be a method insidevillageState
. - Inside
runRobot
, fix the reference tomove
to make it accessible from insidevillageState
. - Test the code. It should work normally but you can still optimize it. Inside
move
, what can you do about the references tovillageState
?
Hints
- When moving the function, make the necessary changes to its syntax now that it’s going to be an object property.
- To call
move
as a method, you would need to access the object it’s contained within first. - For the optimization task, have you thought of
this
?
Solution
This is mostly copy and paste, with two minor edits.
- Change
function move(destination)
tomove: function(destination)
and then literally copy and paste the whole function insidevillageState
. Nothing crazy here. - The other fix is to make
runRobot
be able to call this method as a property ofvillageState
. We can writevillageState.move(action.direction)
but remember thatrunRobot
is receivingvillageState
as an argument using thestate
parameter. So all we need to do is putstate.
in front of the call tomove
. - Finally, since
move
is now insidevillageState
, we can reference the object usingthis
. So just replace all instances ofvillageState
insidemove
tothis
and you’ll be good to go!
let villageState = {
currentRobotPlace: "Post Office",
remainingParcels: PARCELS,
// move function expressed as a method
move: function(destination) {
if (roadGraph[this.currentRobotPlace].includes(destination)) {
let parcels = this.remainingParcels
let movedParcels = parcels.map(parcel => {
if (parcel.place != this.currentRobotPlace) return parcel
return { place: destination, address: parcel.address }
})
let undeliveredParcels = movedParcels.filter(
parcel => parcel.place != parcel.address
)
this.currentRobotPlace = destination
this.remainingParcels = undeliveredParcels
}
}
}
// inside runRobot
// ...
state.move(action.direction)
// ...
Converting move
to a method was a precursor to what we’re going to do next.
Converting ‘villageState
‘ to a Class
Unlike the book’s version, we have defined the village’s state as a simple object. My goal was to make the different components of the project easier to understand before encapsulating them in classes. But why would we want to encapsulate anyway?
The author explains that generally modifying global states is not a good idea. We have a villageState
object that get’s modified at every turn of the robot. What if another part of the code wanted to access the village’s state or have a history of the state? The code thus far does not have an answer.
The goal is to have a class called VillageState
that constructs a new state every time the robot moves. So instead of modifying a global variable, we would return a new state on the fly. When called with the new
keyword, this class will build and return an object similar to villageState
we defined ourselves, but with extra bells and whistles like the move
method inside its prototype.
Note: I highly recommend reviewing the Class basic syntax tutorial to understand how classes work.
Challenge
Build a VillageState
class (capital V) with the following logic:
- It has a constructor with two parameters:
place
andparcels
. - Initialize the constructor with
currentRobotPlace
andremainingParcels
set to the parameters. - Add the
move
method to the class. - Instead of modifying a global variable, have the
move
method return a new instance of the constructor, passing in the appropriate arguments. - To test the class, comment out the call to
runRobot
, and runnew VillageState("post Office", PARCELS)
- Test two moves: one to a location that’s connected to Post Office (like the Marketplace) and one to an unconnected one (like the Farm.)
- Can you fix the problem in the second scenario?
Note: This class will completely replace the old villageState
object, so you would need to delete it.
Hints
- The constructor should reference the properties with
this
. - Yes, we don’t initialize the constructor with
“Post Office”
andPARCELS
because we’re building a general purpose class. It would build new instances using arguments. - To fix the last part, think how the fist
if
statement insidemove
handles the result.
Solution
class VillageState {
constructor(place, parcels) {
this.currentRobotPlace = place;
this.remainingParcels = parcels;
}
move(destination) {
if (roadGraph[this.currentRobotPlace].includes(destination)) {
let parcels = this.remainingParcels
let movedParcels = parcels.map(parcel => {
if (parcel.place != this.currentRobotPlace) return parcel
return { place: destination, address: parcel.address }
})
let undeliveredParcels = movedParcels.filter(
parcel => parcel.place != parcel.address
)
return new VillageState(destination, undeliveredParcels)
}
}
}
Most of this class is pretty much the same as the villageState
object. Let’s see what changed:
- The two properties moved inside the constructor method.
- These properties are no longer hard-coded to “Post Office” and PARCELS. Now we can build states by passing them as arguments.
- The constructor is really a function. That’s why we assign properties inside a constructor with
=
instead of:
. - The
move
method stays as is until the last two lines. We delete them and instead return a new instance withdestination
andundeliveredParcels
. Notice that the logic has not changed. Only instead of modifying a global variable, we return new states. And yes, we’re using our newly defined class inside the class itself!
Testing the Solution
Make sure you comment out the call to runRobot
.
let firstState = new VillageState("Post Office", PARCELS)
console.log(firstState)
// VillageState {currentRobotPlace: 'Post Office', remainingParcels: Array(5)}
Bam! We have a new state with the post office as the starting point and PARCELS for the tasks.
Let’s move around:
let nextState = firstState.move('Marketplace')
console.log(nextState)
// VillageState {currentRobotPlace: 'Marketplace', remainingParcels: Array(5)}
Bam! We’re now in the marketplace.
Let’s look back and see if anything changed:
console.log(firstState.currentRobotPlace)
// Post Office
console.log(nextState.currentRobotPlace)
// Marketplace
Cool. Each state lives its own life and we can still move around and keep track of the history.
Let’s test a move from the post office to the farm:
firstState.move('Farm')
// undefined
Darn it!
Remember when the state was an object, we wanted the state to be updated if the location we were moving towards was connected to the current place, and had the function do nothing otherwise. And that’s exactly what’s happening here: nothing 😄
To fix it, we have to tell the move
method to just return the current state for this particular scenario. Do you remember how can we access the current state from within a class?
Yes. this
.
So we need to add return this
AFTER the if
statement. It tells the move
method to just return the current state as is if the condition above did not trigger.
And this is how the VillageState
class looks like at the end:
class VillageState {
constructor(place, parcels) {
this.currentRobotPlace = place;
this.remainingParcels = parcels;
}
move(destination) {
if (roadGraph[this.currentRobotPlace].includes(destination)) {
let parcels = this.remainingParcels
let movedParcels = parcels.map(parcel => {
if (parcel.place != this.currentRobotPlace) return parcel
return { place: destination, address: parcel.address }
})
let undeliveredParcels = movedParcels.filter(
parcel => parcel.place != parcel.address
)
return new VillageState(destination, undeliveredParcels)
}
return this // return current state if the condition above did not trigger
}
}
And to test it:
firstState.move('Farm')
// VillageState {currentRobotPlace: 'Post Office', remainingParcels: Array(5)}
The robot stays at the post office.
Running the Robot with the ‘VillageState
‘ class
In the object version of the code, we ran the robot like this:
runRobot(villageState, routeRobot, mailRoute)
We need to pass in a state. We used to pass the global variable villageState
but now that the state is defined by a class, we need to initialize a first state for this call to work:
let newVill = new VillageState("Post Office", PARCELS)
newVill
is an instance of the village state that behaves exactly like the villageState
of the old code. But before we can use it in runRobot
to start the program, we need to make one final change to runRobot
. I promise this will be the last time we edit existing code 😅
Please have a look at the current runRobot
function
function runRobot(state, robot, routeMemory) {
let turns = 0
while (state.remainingParcels.length > 0) {
let action = robot(state, routeMemory)
state.move(action.direction)
routeMemory = action.routeMemory
console.log(`Moved to ${action.direction}`)
turns++
}
console.log(`Done in ${turns} turns`)
}
Do you remember when we added state.
in front of the call to move
? In the old code, this call modified the global variable villageState
; in other world, it wasn’t a pure function. We needed it only for its side effect of updating the global state. But now, we have newly generated states any time move
is called. Therefore, to update the state
property inside the loop, we need to re-assign the state instead of just calling state.move
. Can you think how?
The good news is that it’s as easy as simply adding state =
in front of that call.
// inside runRobot
// ...
state = state.move(action.direction)
// ...
To further explain the logic, think what did state.move(action.direction)
used to do in the old code versus now. Previously, this call modified the global villageState
. Every time runRobot
called this function, villageState
was instantly updated and therefore the next call had access to the updated state. In the class version, state.move(action.direction)
does not modify anything, but returns a new state. Therefor, in the same way that we update a counter with i=i+1
, we capture and store the updated state
for the next call in the loop.
And now, we can run our virtual world built using classes:
runRobot(newVill, routeRobot, mailRoute)
Procedurally Generating a Random Village State
Have you noticed running our world with the routeRobot
always finished in 13 turns?
It’s because we’ve been using a hard-coded PARCELS array since the beginning which happened to finish in 24 turns. In the book’s example, a random array is procedurally generated each time the program is run. This functionality is achieved by a static method inside the VillageState
class. This piece of code has nothing to do with the robot’s logic and is just there to illustrate the concept of adding static methods to classes.
Note: I recommend reading the Static properties and methods tutorial from javascript.info to learn more about static methods.
To summarize what a static method is, at least in the context of this project, it is a method that’s accessed through the class itself and not the instance of the class. For example, move
could be called by any instance of VillageState
, which is newVill
in our code. You won’t do VillageState.move
as it’s meaningless. However, the random
method we’re going to build next belongs to the class itself. It’s the class that’s going to build a random state and not any one instance of the class.
There are two ways to add such methods to a class: either using the static
keyword in front of the method inside the class, or by directly adding a property to the constructor:
className.staticMethod = function(){}
we’re going to build a new method called random
that generates an array of parcels with a given length. Remember that the content of each parcel is nothing but one of the 11 places assigned to two properties called place
and address
.
Challenge
Add a static method called random
to VillageState
with the following logic:
- It has a
parcelCount
parameter. Give the parameter a default value (like 5). - The method should pick any two places (from the 11 available places in town) in random and return them in the format similar to
{place: "Town Hall", address: "Marketplace"}
- You need to account for the possibility of the two random places being the same. We don’t want that; a parcel that has the same location and delivery address is meaningless.
- Have the function make as many objects like these as the
parcelCount
and add them to an array - Make the method return a new instance of
VillageState
, passing in any starting position as its first parameter and the resulting array from the previous step as its second. - To test the code, comment out the call to
runRobot
and runVillageState.random()
a couple of times. Examine theremainingParcels
array to make sure the content is random.
Here’s the staring code for you to complete:
VillageState.random = function(parcelCount = 5) {
}
Hints
- Use the helper function
randomPick
and theroadGraph
constant to pick the random place. - You only need to access the keys of
roadGraph
. UseObject.keys()
to do that. - Think of using a
do...while
loop to deal with the two random places being the same issue. Basically keep picking random places until you get a place that is NOT equal to the first randomly picked place.
Solution
Let’s start by initializing an empty array. We’ll be adding parcels to this array and return it in the end:
VillageState.random = function(parcelCount = 5) {
let parcels = []
}
We’ll run a loop that’s parcelCount
in length, then pick two random places and push them to the array during each iteration:
VillageState.random = function(parcelCount = 5) {
let parcels = [];
for (let i = 0; i < parcelCount; i++) {
// pick two random locations
let address = randomPick(Object.keys(roadGraph));
let place = randomPick(Object.keys(roadGraph));
// push them to the array
parcels.push({place, address});
}
}
Now we have a parcels
array which holds random places for the place and address properties. Simply pass it as an argument, together with your desired starting position, like “Post Office”
to create a new instance of the VillageState
class:
VillageState.random = function(parcelCount = 5) {
let parcels = [];
for (let i = 0; i < parcelCount; i++) {
let address = randomPick(Object.keys(roadGraph));
let place = randomPick(Object.keys(roadGraph));
parcels.push({place, address});
}
// return a new instance
return new VillageState("Post Office", parcels);
}
Testing the Solution
VillageState.random()
// VillageState {currentRobotPlace: 'Post Office', remainingParcels: Array(5)}
We got an instance of the village state class that’s exactly like the hard-coded simple object we’ve been using so far. In fact, let’s look inside and see:
You see a currentRobotPlace
, a remainingParcels
, and if you look inside the prototype, you’ll find the move
method.
If you run this line enough times, you will come across parcels with matching place
and address
. We need to tweak the code to deal with this edge case:
VillageState.random = function(parcelCount = 5) {
let parcels = [];
for (let i = 0; i < parcelCount; i++) {
let address = randomPick(Object.keys(roadGraph));
let place = randomPick(Object.keys(roadGraph));
// the tweak is here
do {
place = randomPick(Object.keys(roadGraph));
} while (place == address);
// end of tweak
parcels.push({place, address});
}
return new VillageState("Post Office", parcels);
}
Basically, after we declare the place
variable, we keep re-assigning it until the while
condition returns true. We can’t use an if
here because the call to pickRandom
may pick the same matching locations.
Now you can rest assured you won’t be having redundant parcels.
Run a Randomly Generated World
To run the world with randomly generated parcels, use the random
static method we just defined instead of passing newVill
runRobot(VillageState.random(), routeRobot, mailRoute)
Now you will see the number of turns change randomly every time you run the program.
And with that I end my explanation of Eloquent Javascript chapter 7.
Conclusion
I had a lot of fun writing this tutorial. Many concepts started to deeply sink in as I was reverse engineering the code to be as simple as possible. I had multiple aha moments myself which is the beauty of trying to explain the code you think you already know.
As for the book itself, I love it tremendously. Granted, it’s more about teaching programming concepts and algorithims than Javascript, but I firmly believe it’s awesome that we have options like these. They fill the gap between pure theory and the application. Nonetheless, I acknowledge that this book is not totally beginner friendly. But I promise the more you push the easier it becomes. I found javascript.info to be an excellent companion. There I usually find a nicely written practical tutorial whenever I come across a concept that’s not fully explored in the book. Don’t lose hope and keep pushing.
Good luck.
The Full Code (Part 3)
// Global Variables
const ROADS = [
"Alice's House-Bob's House",
"Alice's House-Cabin",
"Alice's House-Post Office",
"Bob's House-Town Hall",
"Daria's House-Ernie's House",
"Daria's House-Town Hall",
"Ernie's House-Grete's House",
"Grete's House-Farm",
"Grete's House-Shop",
"Marketplace-Farm",
"Marketplace-Post Office",
"Marketplace-Shop",
"Marketplace-Town Hall",
"Shop-Town Hall",
]
const PARCELS = [
{ place: "Daria's House", address: "Post Office" },
{ place: "Bob's House", address: "Ernie's House" },
{ place: "Post Office", address: "Alice's House" },
{ place: "Ernie's House", address: "Farm" },
{ place: "Alice's House", address: "Shop" },
]
const mailRoute = [
"Alice's House", "Cabin", "Alice's House", "Bob's House",
"Town Hall", "Daria's House", "Ernie's House",
"Grete's House", "Shop", "Grete's House", "Farm",
"Marketplace", "Post Office"
]
class VillageState {
constructor(place, parcels) {
this.currentRobotPlace = place
this.remainingParcels = parcels
}
move(destination) {
if (roadGraph[this.currentRobotPlace].includes(destination)) {
let parcels = this.remainingParcels
let movedParcels = parcels.map(parcel => {
if (parcel.place != this.currentRobotPlace) return parcel
return { place: destination, address: parcel.address }
})
let undeliveredParcels = movedParcels.filter(
parcel => parcel.place != parcel.address
)
return new VillageState(destination, undeliveredParcels)
}
return this
}
}
function buildGraph(roads) {
let graph = Object.create(null)
const modifiedRoads = roads.map(road => road.split("-"))
for (let road of modifiedRoads) {
let [start, end] = road
graph[start] ? graph[start].push(end) : (graph[start] = [end])
graph[end] ? graph[end].push(start) : (graph[end] = [start])
}
return graph
}
const roadGraph = buildGraph(ROADS)
function runRobot(state, robot, routeMemory) {
let turns = 0
while (state.remainingParcels.length > 0) {
let action = robot(state, routeMemory)
state = state.move(action.direction)
routeMemory = action.routeMemory
console.log(`Moved to ${action.direction}`)
displayParcels(state.remainingParcels)
turns++
}
console.log(`Done in ${turns} turns`)
}
// Robot Functions
function randomRobot(state) {
return { direction: randomPick(roadGraph[state.currentRobotPlace]) }
}
function routeRobot(state, routeMemory) {
if (routeMemory.length == 0) {
routeMemory = mailRoute
}
return { direction: routeMemory[0], routeMemory: routeMemory.slice(1) }
}
// Helper Functions
function randomPick(array) {
let choice = Math.floor(Math.random() * array.length)
return array[choice]
}
function displayParcels(parcels) {
for (let parcel of parcels) {
console.log(
` Parcel ${parcel.address[0]} > Place: ${parcel.place}, Address: ${parcel.address}`
)
}
}
VillageState.random = function (parcelCount = 5) {
let parcels = []
for (let i = 0; i < parcelCount; i++) {
let address = randomPick(Object.keys(roadGraph))
let place = randomPick(Object.keys(roadGraph))
do {
place = randomPick(Object.keys(roadGraph))
} while (place == address)
parcels.push({ place, address })
}
return new VillageState("Post Office", parcels)
}
// Run the World
runRobot(VillageState.random(), routeRobot, mailRoute)