Assignment 03: Sandbox

A falling-sand simulator, file I/O, exceptions, and 2D arrays

Lab opening · Week 02 full screen
Note

Start with §1, Getting the Code. Clone your repository and open it in VS Code before you read anything else on this page, because everything here refers to files that are in it.

Plan to set aside about 3 to 3.5 hours. Reading the file (loadWorld) and writing the simulation rules (step) are the two long sections, between them they are most of the assignment. The experiment in §9 takes about fifteen minutes and will save you more than that.

Objectives

By the end of this assignment, you will be able to:

  • Open a file with ifstream and check that it actually opened
  • Read numbers with >> and whole lines with getline, and explain why mixing the two needs care
  • Detect the end of a file, and detect a file that ends sooner than it promised
  • throw a runtime_error when a function cannot do its job, and catch it by reference somewhere useful
  • Write formatted output to a file with ofstream
  • Declare, iterate over, and modify a two-dimensional array
  • Pass a 2D array to a function, and explain why the column size is part of the parameter type
  • Use a debugger to find a bug that is invisible in the source

These objectives map to course skills A1, A5, and A6. They also introduce B5 (unit testing), which you finish next week when you write tests of your own, here you only have to read and run the ones you are given.

Note

Lab and homework. You start this in lab on Tuesday, and by then you will have had Monday’s lecture on file I/O, enough for §4 and most of §5. Exceptions come on Wednesday and 2D arrays on Friday, so the rest of §5 and all of §6–§10 are homework. The sections below are in the order you can actually do them.


1. Getting the Code

Do this first, before reading any further. Everything below refers to files that are in your repository, and the next section asks you to open one of them and watch it run. Get the code down and open in VS Code, then read on.

Your private GitHub repository for this assignment is created for you inside the course organization, with the starter code already in it. There is nothing to accept and nothing to set up, if you are on the roster, it is there.

Clone it, replacing YOURUSERNAME with your GitHub username. If gh asks you to sign in, see step 9 of Getting Started:

gh repo clone 26fa-cs112/cs112-a03-YOURUSERNAME
cd cs112-a03-YOURUSERNAME
Note

Cannot find it? Ask GitHub what you have.

If gh repo clone answers Could not resolve to a Repository, this lists every repository you can actually see, spelled exactly right:

gh repo list 26fa-cs112

If yours is in that list, you mistyped the name, copy it from the output and try again. Watch for usernames that already contain a hyphen or a number; the pattern is cs112-<assignment>-<your username>, so the two halves run together and it often looks wrong when it is right.

If the list is empty, or the command errors, that is mine to fix rather than yours to work around. The usual causes are an organization invitation you have not accepted yet (check your email, including spam and the Promotions tab), or a username I mistyped when I built the roster. Either way, email me at eric.araujo@calvin.edu and I will sort it out the same day.

While you wait, do not create a repository yourself and do not start the work somewhere else. Only the repository I create for you is connected to grading, so anything written outside it has to be moved by hand later, and that is a worse afternoon than a short wait.

Open the folder in VS Code, then build it before you change anything:

make

That produces two programs: sandbox (the simulator) and tester (the unit tests). If make works now, you know that any error you see later is yours.


2. What You Are Building

Falling-sand games are a small genre of their own. You get a blank screen, a few kinds of material, and a set of rules about how each one behaves, sand piles up, water finds its way around things, walls stay put, and out of those three or four rules comes something that is oddly hard to stop watching.

None of it is complicated. There is no physics engine. There is a grid of characters, and once per tick you look at every cell and decide whether the thing in it should move. That is the entire program:

Character Element What it does
. empty nothing
# wall never moves
o sand falls, piles up, sinks through water
~ water falls, and spreads sideways

The whole simulation is a 2D array and a nested loop. You will work with a grid of characters, the data structure this week is about, and a simulation that runs for four hundred steps to find out whether you got your loop right. A subtle mistake in a nested loop is invisible on paper. It is extremely visible when your sand falls through the floor.

You will write four functions:

  • loadWorld(), read a world from a file, and complain properly when the file is broken
  • saveWorld(), write the finished world back out
  • step(), advance the simulation by one tick
  • saveReplay(), write every frame into an HTML file you can play back

Everything else, the color drawing, the animation, the emitter loop, the argument parsing, the HTML and JavaScript for the replay player, is written for you. None of it is what this week is about and you shouldn’t be concerned with it.

Tip

See where this is going first, and do it now. The repository you just cloned contains demo.html, a recording of a finished simulator running worlds/showcase.txt. Open it (§3.1 below explains how) and press Play. That is what yours will do by the end of the week.

demo.html is a run of the same program you are about to write, so it is also the reference for what your own replay.html should look like when TODO 4 works.


3. What You Are Given, and What You Write

All the C++ you write goes in one file: sandbox.cpp. You will also add your name to README.md, and you will create worlds/mine.txt in §12, but no other code is yours to touch.

File What it is
sandbox.cpp Yours. Four functions, four TODOs.
sandbox.h Constants and prototypes. Read this first.
main.cpp The driver: arguments, the emitter loop, the step loop
render.cpp Color drawing, and the HTML replay scaffold
tests.cpp catch unit tests for the rules. Read these too.
worlds/ Worlds to run, including three deliberately broken ones
makefile, test.py Build and check
Important

Open sandbox.h and read the whole thing before you write a line. It is short, and it is the contract for the entire program. Three things live there that this page does not repeat:

  • The world is a two-dimensional array of characters, char grid[MAX_ROWS][MAX_COLS]. Two indices, grid[row][col], row first. Every loop you write this week is a nested loop over that array.
  • The four elements are named constants, EMPTY, WALL, SAND and WATER, holding ., #, o and ~. Use the names. A typo in EMTPY is a compile error; a typo in '.' is a silent bug.
  • Every prototype you must match, exactly. What you write in sandbox.cpp has to agree with the header down to the const and the &.

This is a habit worth forming now, not just an instruction for this week. The comments in sandbox.h, main.cpp and tests.cpp are written for you to read, and where a page and the code ever disagree, the code is the one that runs.

Running it:

./sandbox worlds/hourglass.txt 300            # watch it, in color
./sandbox worlds/hourglass.txt 300 --quiet    # no animation, no escape codes

The two arguments are the world file and the number of steps to run. --quiet suppresses the animation, which is what the tests use so they never have to sit through four hundred frames.

Important

Run it now, and expect nothing to happen. The program builds and runs before you have written a line, and this is what it says:

Simulated 300 steps of worlds/hourglass.txt (0x0), 301 frames.

The world loaded as 0x0, so there was nothing to simulate.
That is what happens before TODO 1: loadWorld is empty, so it
never sets rows or cols. Expected right now. Start there.

No out.txt was written. saveWorld, TODO 2, is still empty.
No replay.html was written. saveReplay, TODO 4, is still empty.

That is correct behavior, not a broken repository. The four functions in sandbox.cpp are empty, so the world has no size and neither output file exists yet.

3.1 Opening an HTML file that lives on Coder

Your VS Code runs on your laptop, but your files live on the Coder server. So double-clicking demo.html in the Explorer just shows you the HTML source, there is no browser on the far end to render it.

There are three ways round that. Use the first one.

1. The HTML Preview extension, easiest. Open the Extensions panel in the left sidebar, search for HTML Preview by George Oliveira, and install it. Then open demo.html and use the preview button in the editor’s top-right corner. The page renders in a tab inside VS Code, so nothing has to travel back to your laptop and there is no server to remember to stop.

Install it once and it is there for the rest of the semester. You will want it again for your own replay.html later this week.

2. Serve the folder, if you would rather use a real browser. In the VS Code terminal, from your repository folder:

python3 -m http.server 8000

VS Code notices the new listening port within a second or two and forwards it automatically, you will see a notification, and a PORTS tab appears next to the terminal. Now open this in your own browser:

http://localhost:8000/demo.html

Press Ctrl-C in the terminal to stop the server when you are done. The same address with replay.html on the end shows your own runs later on.

3. Download it. Right-click the file in the Explorer and choose Download… to copy it to your laptop and open it normally. Handy for sending a replay to somebody, and clumsy while you are still iterating, because every new run means downloading it again.

Tip

How the color works, since people always ask. render() prints the character , which fills the top half of a character cell with the foreground color and leaves the bottom half showing the background color. So one printed row draws two grid rows, and the cells come out square instead of stretched. Nothing you write ever touches a color, your code moves char values around a 2D array, and the mapping from character to color happens once, at the very end, in render.cpp.


4. The World File Format

A world file looks like this:

20 40
2
5 10 sand
8 30 water
....................####################
..........#...............#.............
........................................

Line by line:

  1. rows cols, the size of the grid.
  2. A count, how many emitter lines follow.
  3. That many emitter lines, each row col element, where element is the word sand or the word water. An emitter drops one grain of its element every step, if its cell is free.
  4. Exactly rows lines of exactly cols characters, the grid itself.

What comes out the other side is a char array with two indices. Line r of the grid becomes grid[r], and character c of that line becomes grid[r][c].

A small world file at the top, an arrow labeled loadWorld pointing down, and the grid it produces below. The file has eight lines: 4 6, then 2, then 0 2 sand, then 2 5 water, then four rows of six characters, dot dot dot dot dot dot, dot dot hash dot dot dot, dot dot dot dot dot dot, and six hashes. Labels mark the first line as rows and cols, the second as how many emitters follow, the next two as each giving a row, a column and an element, and the last four as rows lines of cols characters. The grid is four rows by six columns with column numbers zero to five along the top and row numbers zero to three down the left, each cell holding the character from the matching position in the file. The cell at row one column two holds a tinted hash. The cells at row zero column two and row two column five are still dots but each carries a dashed maroon ring; a note says the two rings are the emitters and are not cells, that each drops one grain per step, sand at grid zero two and water at grid two five, and that both cells load EMPTY. A legend gives dot as EMPTY, hash as WALL, o as SAND and tilde as WATER. Below, a card headed what loadWorld fills in gives rows equals 4, cols equals 6, emitCount equals 2 and grid one two equals WALL, then a table captioned three arrays, one column per emitter: the rows are labeled emitRow, emitCol and emitElem, the columns are headed bracket zero and bracket one, and each column is outlined, holding 0, 2, SAND in the first and 2, 5, WATER in the second. A note says an emitter is a column, not an array, that its row, column and element are held together by nothing except the index they share, and that emitCount says how many columns are in use while the rest of each array is untouched. A final card, headed capacity is not size, shows a large dashed rectangle with a small tinted rectangle in its corner, labeled as the whole box being char grid 64 by 128 always reserved and the tinted corner being rows by cols, the part in play, not to scale, with the reminder that every loop stops at rows and cols rather than at the maximum.
Figure 1: What loadWorld turns a file into. The two emitters are the things on the left that do not become characters in the grid: each is three values spread across three parallel arrays, and the cells they name are still EMPTY when loading finishes.

You will need to use >> for the numbers and the element words, getline for the grid rows (which contain . and # and must be read whole), and an honest end-of-file condition when the grid runs out.

Warning

The >> / getline trap. After your last >>, the newline that ends that line is still in the stream. Your very first getline would return an empty string, and every grid row would be off by one. Call getline once before the grid loop and throw the result away.

fin >> emitCount;
string line;
getline(fin, line);      // eat the rest of the line the numbers were on

for (int r = 0; r < rows; r++) {
    getline(fin, line);  // now this is grid row 0
    ...
}

5. TODO 1, loadWorld

Open sandbox.cpp. The first TODO is the largest one in the assignment.

Important

The step-by-step instructions are in sandbox.cpp, not here. The comment block above loadWorld is the most detailed thing you have: it gives the file format again, the shape of the function line by line, the reading trap that catches everybody, and the exact words each error message must contain. Read it first, and keep it open beside you while you write.

This section is the why. It explains what the parameters are doing, what throw is and where it goes, and how to test the result. The two are meant to be read together, and the same is true of TODO 2, 3 and 4: the page explains, the code instructs.

void loadWorld(const string& filename,
               char grid[][MAX_COLS], int& rows, int& cols,
               int emitRow[], int emitCol[], char emitElem[], int& emitCount);

Everything after filename is an out parameter, loadWorld fills it in for whoever called it. The int& parameters are references, the same out parameters you used in week 1. Arrays are already passed by reference in C++, so grid and the three emitter arrays need no &.

The three emitter arrays are one array of emitters, taken apart. This catches people, so it is worth a paragraph. An emitter is three things: a row, a column, and which element it drops. There is no type in this program that holds all three, so they are stored in three separate arrays, and emitter number i is whatever sits at index i in each of them:

int  emitRow[MAX_EMITTERS];    // emitter i is at row emitRow[i] ...
int  emitCol[MAX_EMITTERS];    // ... column emitCol[i] ...
char emitElem[MAX_EMITTERS];   // ... and drops emitElem[i]
int  emitCount;                // how many of the slots are in use

That is a parallel arrays layout. Reading 0 2 sand from the file means writing 0 into emitRow[0], 2 into emitCol[0] and SAND into emitElem[0]: one line of the file, three separate assignments, held together by nothing but the index. Get the indices out of step and emitter 0 drops the wrong element in the wrong place, with no error anywhere.

emitCount is the only thing that says how much of each array is real. The rest of all three is uninitialized, exactly like the part of grid past rows and cols.

Look at Figure 1 again with this in mind: the emitter table in the lower card is three arrays drawn as three rows, and each emitter is one column through all three.

Note

Why does grid have a size in it? Look at the type: char grid[][MAX_COLS]. The row count is missing but the column count is not. A 2D array is one contiguous block of memory, laid out row after row, and to find grid[r][c] the compiler computes r * MAX_COLS + c. Without knowing how wide a row is, it cannot do that arithmetic. This is the same pointer arithmetic you did in week 0, with one more dimension on top.

Eight parameters is a lot to carry around, and you are about to feel it. So is splitting one emitter across three arrays. If both of those feel clumsy, that is the correct reaction, and they are the same complaint: there is no type here that holds several values together as one thing. Next week you will meet classes, which exist in large part to solve exactly that. An emitter becomes one value you can pass, copy and keep in a single array, and the signature above gets shorter.

Throwing when the file is wrong

Look at the signature again: loadWorld returns void. It has no way to tell you it failed. If the file is missing, or the grid runs out halfway, the function reaches its closing brace with rows, cols and grid half filled in, and main() carries on simulating garbage.

You could change it to return a bool and check it at the call site. That works, and it is what C does, and it fails the moment somebody forgets to check. C++ gives you something that cannot be forgotten.

What throw does. It abandons the function on the spot. Nothing after it in loadWorld runs, no half-filled grid gets handed back, and control jumps straight to the catch block in main(), however many function calls deep you were. This is Wednesday’s lecture; what follows is the part you need to write TODO 1.

Where it goes. Inside loadWorld, at the moment you notice the problem. The first check is the file itself:

ifstream fin(filename);
if (!fin) {
    throw runtime_error("cannot open file: " + filename);
}

runtime_error is a type from <stdexcept>, already included at the top of sandbox.cpp, and the string you hand it is carried along with the exception. That is the string main() prints. Everything below that if is now safe to write as though the file opened, because if it did not, execution never gets there.

The second check has the same shape, and so do the other two:

fin >> rows >> cols;
if (rows < 1 || rows > MAX_ROWS || cols < 1 || cols > MAX_COLS) {
    throw runtime_error("bad dimensions: " + to_string(rows)
                        + " by " + to_string(cols));
}

Read a value, test it, throw if it is wrong. Then the rest of the function can be written as though it is right, because if it were not, execution would never have got there.

Do not expect exactly four throw lines, though. bad dimensions covers both the grid size and the emitter count. unknown character covers both an emitter’s element name and a character in the grid. And the checks that guard the grid belong inside the loop that reads it, so they run on every row and every character rather than once. Put each check where the value it tests has just been read, and the count takes care of itself.

These are the four things that must be caught, and the words your message must carry when you catch them. The autograder looks for the quoted words somewhere in the message; the rest of it is yours to write, and a helpful one is worth writing, because it is what a student reads when their own world file is wrong.

When Your message must contain
The file will not open cannot open file
rows or cols is outside 1 … MAX_ROWS / 1 … MAX_COLS bad dimensions
The file runs out of rows, or a row is too short grid ended early
A grid character is not . # o ~, or an emitter’s element is not sand or water unknown character

You do not need to check that emitters sit inside the grid, main() already does that.

main() catches these for you, at the bottom of the file:

catch (const runtime_error& e) {
    cerr << "Error: " << e.what() << endl;
    return 1;
}

Note const runtime_error&, caught by reference. Catching by value would copy the exception object, and with a hierarchy of exception types it would slice off everything the derived type added. Catch exceptions by reference. Always.

Tip

Notice what the try block in main() wraps: the load, the whole simulation, and the saves. A throw anywhere in there unwinds the stack straight down to that one catch. You do not have to check a return value at every level, and there is no way to accidentally continue with a broken world. That is the argument for exceptions in one paragraph.

Try it against the three broken worlds you were given, plus one that is not there at all, which needs no file. Zero steps and --quiet, because you are testing the load, not the simulation, and neither of those has anything to do with four hundred frames of animation:

./sandbox worlds/badDims.txt 0 --quiet       # bad dimensions
./sandbox worlds/short.txt 0 --quiet         # grid ended early
./sandbox worlds/badChar.txt 0 --quiet       # unknown character
./sandbox worlds/doesNotExist.txt 0 --quiet  # cannot open file

A case you have handled prints one line and stops. This is the reference solution’s wording; yours will differ, and only the quoted words from the table have to match:

Error: grid ended early: expected 8 rows, found 5

A case you have not handled yet looks like this instead:

Simulated 0 steps of worlds/short.txt (8x12), 1 frames.
No out.txt was written. saveWorld, TODO 2, is still empty.
No replay.html was written. saveReplay, TODO 4, is still empty.
Note

Read that second one carefully, because two thirds of it is noise. The two No ... was written lines are not about this test at all. They are the program reporting on TODO 2 and TODO 4, and they will be there after every run until you write those. Ignore them while you are on TODO 1.

The line that matters is the first one. Simulated 0 steps means the program accepted a file you know is broken and carried on. That case is not handled yet. The whole point of the check is that this line should never appear for any of the four.

Without --quiet it is worse: an unhandled broken file loads as some plausible size and animates, so you get forty lines of blocks scrolling past before those same three lines. That is still the same message, buried.


6. TODO 2, saveWorld

Take a deep breath. You are about to save the world.

Much shorter than the last one. Write the grid out in the same format loadWorld reads:

line 1     rows and cols, separated by a space
line 2     the emitter count, which you always write as 0
then       rows lines of cols characters

Line 2 is the same field loadWorld reads on line 2 of a world file, and you always write 0 there. Not because the world had no emitters, but because you are saving the finished grid. The sand has already fallen. An emitter is an instruction to keep producing more of it, and out.txt is a photograph, not a scenario. Reload it and you should get the picture back, not a world that starts filling up again.

That is also what makes out.txt a valid world file in its own right: same header, same shape, so ./sandbox out.txt 10 works. Run ./sandbox worlds/oneGrain.txt 5 --quiet and put the two side by side, the input on the left and what you wrote on the right. One grain, five steps, five rows lower:

worlds/oneGrain.txt           out.txt
16 9                          16 9
0                             0
.........                     .........
....o....                     .........
.........                     .........
.........                     .........
.........                     .........
.........                     .........
.........                     ....o....

An ofstream behaves exactly like cout, same <<, same everything:

#include <fstream>

ofstream fout(filename);
fout << rows << " " << cols << "\n";
Tip

Check your first two functions against each other. Run zero steps: nothing moves, so what comes out must be exactly what went in.

./sandbox worlds/hourglass.txt 0 --quiet
diff <(tail -n +3 worlds/hourglass.txt) <(tail -n +3 out.txt)

If diff prints nothing, loadWorld and saveWorld are both right. If it prints something, you have found a bug before writing a single rule.


7. The Rules

Once per step, every grain gets at most one move. Walls never move. Empty cells are not grains and do nothing.

Sand, try these in order and stop at the first one that works:

  1. The cell below is empty → move down.
  2. The cell below holds water → swap places with it. (Sand is heavier; it sinks through, and the water ends up above it.)
  3. The cell below-left is empty → move down-left.
  4. The cell below-right is empty → move down-right.
  5. Otherwise → stay put.

Water, the same idea, with two more options:

  1. The cell below is empty → move down.
  2. The cell below-left is empty → move down-left.
  3. The cell below-right is empty → move down-right.
  4. The cell to the left is empty → move left.
  5. The cell to the right is empty → move right.
  6. Otherwise → stay put.

Sand gets lower or stays. Water gets lower if it can, and if it cannot, it spreads out, which is the entire difference between a pile and a puddle.

Important

Left is always tried before right. Every one of these lists is in strict priority order, and ties never go to chance. That matters more than it looks: the version of this project that ran last spring picked left or right at random, which meant no two runs of the same world ever agreed, and nothing about it could be checked automatically. A fixed tie-break costs you a little realism, water leans left rather than levelling perfectly, and buys you a simulation you can test.

Before you look at any neighbor, check that it exists: r + 1 < rows, c - 1 >= 0, c + 1 < cols. The bottom row has nothing below it, and reading grid[rows][c] is exactly the kind of out-of-bounds access C++ will happily let you do and then behave strangely about.


8. TODO 3, step

void step(char grid[][MAX_COLS], int rows, int cols);

Visit every cell. Apply the rules. That is the whole function.

Write it now, with two nested loops, and pick whichever loop direction seems natural to you. Do not think about it too hard. Then go straight to §9, which is about what happens next.

Meet catch

tests.cpp contains unit tests for each rule, on grids small enough to check by hand. Open it. Each test builds a tiny world, calls step() once, and states what the grid must look like afterwards:

TEST_CASE("sand falls into empty space below", "[sand]") {
    build(grid, {"...",
                 ".o.",
                 "..."}, rows, cols);

    step(grid, rows, cols);

    REQUIRE(picture(grid, rows, cols) == vector<string>{"...",
                                                        "...",
                                                        ".o."});
}

These are written with catch, a testing framework that is nothing but the single header catch.hpp sitting in your repository. REQUIRE is a close relative of the assert() you used in a01, with one useful difference: when a REQUIRE fails, it prints what it got next to what it wanted, and then moves on to the next test instead of killing the program. So a run tells you about all your failures at once.

make tester
./tester                              # all of them
./tester -s                           # all of them, showing every REQUIRE
./tester "sand sinks through water"   # one of them, by name

Read them before you write anything. They are the specification for §7, written in a form the compiler can check, and the ability to state what code must do before writing it is most of what next week’s assignment is about.


9. The Experiment

You have a step(). Before you run a big world, run the smallest one there is.

worlds/oneGrain.txt is a tall empty column with a single grain of sand near the top and a floor at the bottom. Open it and look at it.

Write down your answer to this before you run anything:

After one call to step(), which row is the grain in?

Now run exactly one step:

./sandbox worlds/oneGrain.txt 1 --quiet
cat out.txt
Warning

If the grain is where you predicted, run ./sandbox worlds/oneGrain.txt 1 again without --quiet, and also try ./tester "a grain moves at most one cell per step". If both agree with you, you happened to pick the right loop direction the first time, read the rest of this section anyway, because it explains why you were right, and you will need that in week 5.

For most of you, the grain is sitting on the floor. One step, and it fell the entire height of the column.

Nothing in your rules says that. Rule 1 moves a grain down by one cell. Read your step() again, it is not wrong. Reading it more carefully will not help, which is exactly why this is the right moment to stop reading and start debugging.

Find it with the debugger

So far, when you wanted to know what your program was doing, you added a cout. That works, but it makes you guess in advance what is worth printing. A debugger does something better: it stops your program mid-run and lets you look at everything.

This is the moment it earns its keep. Take fifteen minutes to learn it here and you will use it for the rest of the course.

Note

Everything below is already set up in your repository. .vscode/launch.json holds three ready-made debug configurations, and the -g flag in the makefile puts the debug information into the executable that makes any of it possible. (Without -g, the debugger cannot tell which machine instruction belongs to which line of your source, and breakpoints do nothing.)

1. Set a breakpoint. Open sandbox.cpp and find the line inside step() where sand moves down. Click in the margin just to the left of its line number. A red dot appears. That is a breakpoint: a line where you want the program to stop.

2. Start debugging. Press F5. At the top of the window a dropdown appears choose Debug one unit test. That builds the project and runs ./tester "a grain moves at most one cell per step" under the debugger: one grain, one step, nothing else happening anywhere.

3. Read the screen. Your program stops on the red dot, and a yellow arrow marks it. Three things are now worth looking at:

Where What it shows
Variables, upper left every variable in scope, right now, including r and c
Call Stack, lower left who called the function you are stopped in
The toolbar at the top Continue (F5), Step Over (F10), Step Into (F11), Stop

Look at r and c in the Variables panel and write them down.

4. Continue. Press F5 again. The program runs until it hits the same breakpoint the next time. Look at r and c again.

Here is the question the debugger answers, and the cout would not have: within a single call to step(), how many times does that line run for the same grain?

It should be exactly once. Keep pressing F5 and watching r. You will see it run again. And again. And again, once per row, all the way to the floor.

Tip

Click the top frame’s caller in the Call Stack panel to jump to the line in tests.cpp that called step(). That is how you answer “how did I get here?”, and it becomes essential in week 9 when you start writing recursive functions and the call stack is twelve frames deep.

What is actually happening

Suppose your outer loop runs top to bottom. You are at row r, you find a grain, and you move it down into row r + 1.

Then your loop moves on to row r + 1, and finds the same grain there, sitting in a cell you have not visited yet this pass. Nothing marks it as having already moved. So it moves again. And again, once per row, all the way down, in a single step. The grain does not fall fast; it gets carried by your own loop.

The fix is to visit rows in the order that makes a moved grain land somewhere you have already been:

Important

Scan from the bottom row upward. Start at rows - 1 and count down to 0. A grain that moves down lands in a row this pass has already finished with, so it cannot be picked up a second time. One grain, one move, one step.

Change the outer loop, rebuild, and run the experiment again. The grain moves one row.

Two things worth taking away from this:

  • This bug is invisible in the source. Every individual line was right. Only the order in which the loop visits cells was wrong, and the only way to see it was to watch the program run. That is what a debugger is for, and it is why “read your code again” is not always the answer.
  • There is a second way to fix it: keep two grids, read from one and write to the other, and swap them at the end of the step. Then nothing you write during a pass can affect what you read during that pass. You do not need it here, but hold on to the idea, it comes back.

One more of the same, smaller

There is one move that scanning bottom-to-top does not protect you from. Sand only ever moves down into an already-finished row. Water rule 5 moves it sideways into the next column, which your inner loop has not reached yet. A moment later it arrives there, finds the same grain, and gives it a second turn in the same step.

The fix is the same idea in one line: after moving water right, step the column counter past it.

grid[r][c]     = EMPTY;
grid[r][c + 1] = WATER;
c++;              // this grain already moved; do not visit it again

Now run something worth watching:

./sandbox worlds/hourglass.txt 300
./sandbox worlds/cistern.txt 250
./sandbox worlds/quarry.txt 200

10. TODO 4, saveReplay

The last function, and the shortest interesting one. It writes every frame of the run into a single HTML file with a play button and a scrubber.

The page and the JavaScript player are handed to you as two string constants, REPLAY_HEAD and REPLAY_TAIL. You write what goes between them:

fout << REPLAY_HEAD;

// one line:  rows, cols and frameCount, separated by spaces
// then:      for each frame, `rows` lines of `cols` characters

fout << REPLAY_TAIL;

frames is a 3D array, one more dimension than the grid, with the frame number on the front:

void saveReplay(const string& filename,
                char frames[][MAX_ROWS][MAX_COLS],
                int frameCount, int rows, int cols);

frames[f][r][c] is the character at row r, column c of frame f. Notice that the two rightmost sizes are in the parameter type now, for the same reason MAX_COLS was there before: the compiler needs both to do the arithmetic.

So this is the nested loop from saveWorld with one more loop wrapped around it. That is genuinely all it is, and the payoff is a color animation you can send to somebody.

Warning

Nothing else goes in the file. No separators between frames, no blank lines, no trailing message. The JavaScript counts lines, so one extra line shifts every frame after it.

sandbox.h declares the two constants like this:

extern const string REPLAY_HEAD;
extern const string REPLAY_TAIL;

but the constants at the top of the same file have no extern:

const char EMPTY = '.';
const int  MAX_ROWS = 64;

Both are const, both are at file scope, and only one pair needs the keyword. The reason is a rule that surprises most people the first time they meet it.

A const at file scope is private to its own .cpp file. Every source file that includes sandbox.h gets its own EMPTY, its own MAX_ROWS, and nobody minds: a one-byte constant the compiler folds into the instruction that uses it costs nothing to duplicate, and four private copies can never disagree. (Anything not const behaves the opposite way and is shared by default, which is the part that makes this feel backwards.)

Now try the same thing with the replay page. REPLAY_HEAD is several kilobytes of HTML and JavaScript, and it is a std::string, so it is not folded into anything: it is a real object that has to be built at run time before main starts. Four private copies means four constructions of the same large string and four times the memory, for one program.

extern opts out of that rule. It turns the line into a declaration: a promise that somewhere in this program there is a const string by that name, with the linker left to find it. The definition, the line that actually makes the object, sits in render.cpp:

const string REPLAY_HEAD = R"HTML(<!DOCTYPE html> ... )HTML";

One object, built once, and every .cpp that includes the header refers to that same one.

One detail that bites later, because it fails at link time rather than compile time: the definition in render.cpp does not repeat the word extern, and does not need to, because render.cpp includes sandbox.h on its first line. The extern declaration is already visible, so the definition inherits it. Remove that #include and the definition quietly goes back to being private, render.cpp still compiles perfectly well, and the build dies at the end with an undefined reference coming from a file you did not touch.

If you want the rules rather than this one example, learncpp on internal linkage and on external linkage are the clearest short treatment.

When it works, open replay.html the same way you opened demo.html back in §3.1:

python3 -m http.server 8000       # then http://localhost:8000/replay.html

Put the two side by side, demo.html is a recording of a correct simulator, so if yours looks different, that difference is a bug you can see.

The file is completely self-contained: no images, no libraries, no network. You can download it and email it to someone and it will just work.


11. Testing Your Work

make test

Twenty-nine checks, in eight groups:

Group What it looks at
Bad world files (4) your program survives each broken file and says why
The rules (6) the catch tests in tests.cpp, one per rule plus scan order
Whole scenarios (4) a full run compared against the reference
The replay (2) replay.html is shaped the way the player expects
Your own world (1) see §12
More bad world files (4) the same requirements on files built in a scratch folder
More scenarios (6) six more worlds, each leaning on one rule
Round trip (2) your out.txt reloads as a world file, plus one more replay

Groups 6 to 8 build their world files on the fly rather than reading worlds/, so they check the same requirements on inputs you have not seen. Nothing in them is new: if your code genuinely works, they pass.

The scenario checks compare a fingerprint of your out.txt rather than the file itself, so a failure can tell you that something is wrong without telling you what the right answer looks like. When one fails, that is the moment to run the same world without --quiet and watch where it goes wrong. That is what the color is for.

Every failure comes with a hint. Work through them one at a time, the four functions are independent enough that you can get partial credit for each as you go.

Note

There are no hidden tests. What you run with make test is exactly what the grader runs, all 29 checks. If it passes here, it passes there.

Grading always runs the official test suite and the official worlds/ files, not the copies in your repository. Editing them will not change your score.


12. Design Your Own World

Make a world of your own and save it as worlds/mine.txt.

An hourglass, a waterfall, a maze that sand has to find its way through, your initials in walls with sand pouring over them. Ten minutes, a text editor, and the format from §4.

It is graded on whether it loads and runs, plus two sanity conditions so an empty grid does not count: at least 8 wall characters (#), and either at least 4 grains of sand or water in the grid or at least one emitter. That is the whole rubric. No points for artistry, points for artistry are awarded verbally, in class, on Friday, when we run the good ones on the projector.

./sandbox worlds/mine.txt 300
Tip

Two things worth knowing while you build one. rows and cols must be within MAX_ROWS (64) and MAX_COLS (128), and no grid line may be shorter than cols, a short line is the grid ended early error you spent §5 implementing, and it is by far the most common mistake here. And an emitter is much more interesting than a grid full of sand: one emitter running for three hundred steps gives you a stream, not a lump.


13. Submit

Important

Before pushing, open README.md and add your name.

In VS Code, click the Source Control icon in the left sidebar, type a commit message, click the checkmark to commit, then sync. From the terminal:

git add .
git commit -m "Complete a03"
git push

Make sure worlds/mine.txt is included, git status will tell you.

Every push is checked automatically. Within a minute or two the Actions tab of your repository shows a run named Autograde. Open it: the job summary says how many checks passed, and names any that failed. Push as many times as you like; the most recent submission is the one that counts.