Lab 3: User Interaction and 2D Games

Due Monday, 28 September 2020, before 7am

This is a partnered lab assignment. If you have not already done so, please set your Partner preferences through the TeamMaker service. Your can pick a specific partner, a random partner, or to work alone. I strongly encourage you to work with a partner when given the option. You may choose a partner from either lab section. If you pick a specific partner, your partner must also choose you before the application will create a team and create a git repository for you.

1. Lab Goals

Through this lab you will learn to

  • Build a projection transform to convert from a grid coordinate system suitable for a 2D game to webGL2 clip coordinates.

  • Work with the GLSL mat4 and TWGL m4 types.

  • Use event listeners to respond to user input in a webGL2 program.

  • Build a model of small 2D interactive game on the CPU

  • Use WebGL2 to render the game model in real time.

2. References

3. Cloning Files

After the setup from last week, you should only need to move to your labs folder and clone your lab3 repo from the Github CS40-F20 org

cd
cd cs40/labs
pwd                   # should list /home/you/cs40/labs
git clone git@github.swarthmore.edu:CS40-F20/lab3-yourTeamName

Longer GitHub setup instructions are available off the Remote Tools pages if you need help. You will be assigned a random team name that is the concatenation of material property, e.g., translucent, and an animal, e.g., Porcupine.

Similar to prior labs, we will link to the cs40lib of third party libraries. But this week, you should not need to create the primary link in your ~/cs40/labs folder. And since I did not add a lib file by default to your lab3 repo, there should be no need to remove a bad link. You just need to add one good link in your lab3 folder.

cd
cd cs40/labs   #make sure you are in labs, not lab1
cd lab3-yourTeamName   #use your team name
ln -s ../cs40lib ./lib   #pay attention to the dots

4. Running and Viewing your Lab

You will use the same process as lab1 to start a webserver with python3 and tunnel with ngrok.

To run the server, change to the directory you want to serve and run the following command using python3. Note the & at the end to run the server in the background.

cd
cd cs40/labs/lab3-yourTeamName
python3 -m http.server &

You should get a message saying

Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/)

If instead you get a long error message ending in

OSError: [Errno 98] Address already in use

It may mean someone else is running a server on the same port, possibly you. If it is you, you can either continue using the existing server, or kill the old server using

pkill python3

You can also try running your server on a different port other than 8000

cd
cd cs40/labs/lab3-yourTeamName
python3 -m http.server 8080 &

Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/)

As long as we eventually get a Serving HTTP message, we are ready to proceed to the next step

4.1. ngrok http 8000

The address http://0.0.0.0:8080/ is only usable if you are on campus and logged in directly to the machine you started the server on.

The ngrok tool will create a temporary public URL that you can use to access your webserver from anywhere. To run it, just provide the protocol and port number that your webserver is running on.

ngrok http 8000

This should pop up a small display in your terminal listing a Forwarding URL.

Forwarding                    https://b985958321b9.ngrok.io

The session will last at most 8 hours, or until you stop the process with CTRL-C

5. Overview

You should now be able to go to the link given by ngrok in a browser on your personal computer at home. If everything works, you should see the lab3 start page, which is quite blank.

The final version will be an interactive version of a classic 2D snake game shown below.

Using the arrow keys to change the direction of the snake, you will attempt to eat the food pellets while avoiding the walls and avoiding biting your own tail. You will grow each time you eat a pellet and the game ends when you run into a wall or cross over yourself.

There is not much to draw in this lab. You are using the basic setup of lab2 to draw a scene consisting of many grid cells, where each cell can contain a wall, a food pellet, or part of the snake body.

Most of this lab will be creating an abstract model of the game logic on the CPU side using JavaScript classes, a variant over the simple object type we have been using the past two weeks. Additionally we will use event listeners to the WebGL2 canvas to listen for user input including key presses and mouse clicks and update our internal model.

We will then use WebGL2 to render and animate our model to make the game interactive. The one small WebGL2 component we will implement this week is a small change of frame that allows us to work in a convenient coordinate system for the game board and then convert the geometry to clip coordinates through the use of a 4x4 matrix transform. We will use the TWGL m4 library to build our matrix transform once on the CPU side and then pass it as a uniform to our vertex shader as a mat4 type in GLSL.

6. Getting Started

Despite nothing showing on the screen at first, some components of this lab have been started for you. Some of the basic setup is similar to lab2. You will see a skeleton of a drawScene function, and the makeTriangle/drawTriangle and makeSquare/drawSquare functions have been included. The makeSquare function is a bit different, as the function can now take a parameter d as the side length and the upper left and lower right coordinates of the square are (0,0) and (d,d), respectively.

By default, in init, the vao is set to use a square of side length d=1. If you tried to call drawSquare() now in drawScene, nothing would happen even though the coordinates of the square describe the upper right quadrant of clip space. This is because the square has the wrong orientation in clip space. If you comment out the line gl.enable(gl.CULL_FACE); in init, you should see a red square if you called drawSquare() in drawScene.

But we want to do something a little different with our space. First, we are going to divide the canvas (defined in pixels) into a 2D grid of cells, each a given blockSize number of pixels wide and tall. This 2D grid will form an internal representation of our game board. In init, we make a new World object with a blockSize=20. For the default canvas size of 800x600, this will make a 40x30 grid of cells. We will render each cell in our grid as a single square in WebGL2.

Internally, it will be helpful to use a coordinate system where the coordinates can map easily onto the rows and columns of the grid. We will denote the coordinate (0,0) to be the upper left corner of the leftmost cell in the top row. In general, a cell at row and column (r, c) will have an upper left coordinate of (c,r) and lower right coordinate of (c+1, r+1). Note the natural tendency is to use row, column indexing and (x,y) coordinates, but a column index is really an x-coordinate.

6.1. The World Class

Lab3 provides a sketch of the 2D game board in the World class. A JavaScript class is similar to the base object types we have been using in the prior labs, but it provides a way of easily creating multiple instances and grouping functions/methods. You’ll see the World class begins with the class World { definition, and has a special constructor method. You can make a new instance of this World class by using the new keyword and the name of the class, then providing the arguments to the constructor. See in init where we have world = new World(gl.canvas, 20);.

Similar to the self variable in python, the this keyword is a reference to the current object inside the class. It is used to refer to member variables and methods within the class. Some basic state for the world class has been added for you along with some basic methods. After construction, you can use the properties/methods like you would on any other object.

One of the things this World class does for you already is sets up the 2D array of cells, and assigns each one of them the label labels.open (defined before main in the globals section).

The World constructor also creates a snake object using a separate Snake class, which is much less complete, and updates the game board to place the snake in the center of the grid.

6.2. Adjusting the coordinate system

Your first step in modifying the program should be adding support for changing the coordinate system. You will build a matrix proj in init that converts grid coordinates to clip coordinates. In grid coordinates, (0,0) is in the upper left and (ncols,nrows) is in the lower right, where ncols and nrows are the number of columns and rows in the game grid.

Look over the TWGL docs and the notes on change of frame to build a suitable transformation matrix. init currently sets the final value of proj to uniforms.u_projection which is copied to the GPU and the vertex shader in render.

Your projection matrix is correct when a simple drawSquare() in drawScene() renders a small red square in the upper left corner. Your square should draw even when culling is enabled.

6.3. Drawing the snake.

The snake by default is set in the middle row and column. You should now be able to set a u_shift uniform in drawScene to move the square to the center before drawing it. Note you the u_shift amount is specified in grid coordinates, so it should be easier to compute. You should be also be able to change the color of the snake. See the colors global for some options, and feel free to change the defaults.

This hand coding of drawScene is just for practice as you learn how the uniforms adjust the scene. Soon you will use loops to draw the entire grid.

7. Add Walls and Draw Grid

Once you have a basic understanding of how drawSquare is influence by the uniforms and your projection matrix is correct, you are ready to add multiple elements.

Your grid should have walls along the perimeter so the snake cannot escape the boundary of the canvas. Update World.makeGrid to add some wall elements. You can use the label.wall id to set a particular grid cell to a wall.

Once you have multiple non-open elements (many walls, one snake square), modify drawScene to loop over the grid and draw each non empty cell in the correct location with the correct color.

7.1. Add food

Add support in your World class for adding a food pellet at a random open grid location. Your drawScene function should be able to render the food object without much modification.

8. Adding motion

The next step is to animate your scene and interact with user input. JavaScript can connect various interactive events on HTML objects to functions of your own design that run when an event is triggered. Some examples are started for you in setupListeners(). We want the WebGL2 canvas to listen for key presses and notify us when the user presses the arrow keys.

By default, the canvas, won’t listen for keypresses until it has focus, which happens when a user clicks on the canvas. I have added a focus listener that unpauses the game and listens for keypresses once the user focuses on the canvas. There is a bit of CSS setup required for this to work too, but this has already been done.

The setupListeners function and World.togglePause method also show how to display text in an overlay box using innerHTML. This can be used to show status messages on top of the canvas (outside a WebGL2 context). togglePause also shows how to use css to show or hide this status text.

You probably do not need to modify the focus listener or modify togglePause, but you will need to extend the keydown listener to listen to other keys and make other updates to the game state. Some of these updates can be done in the Snake class, while others will be done in World class.

8.1. Sketching a Snake class

The snake object starts in the center as a single pixel. The user can press the arrow keys to move the snake in different directions. A snake cannot make a 180 degree turn in a single step. If the user has not pressed a key recently the snake keeps moving in the current direction unless it hits a wall or itself, at which point the game ends. When a snake passes over a cell containing food, it grows by three additional cells.

The Snake class should contain enough state to adequately update and inspect the snake properties. Some likely important properties:

  • The current direction the snake is traveling

  • If the snake is alive

  • A sequential list of all cells composing the snakes body.

To represent the snake’s body, I recommend an array of [row, col] pairs, with the head of the snake being at the first index, e.g., body[0] = [row, col].

To update the snake’s body, imagine moving every grid cell in the body starting from the tail forward to the position one spot ahead in the array. The only cell that does not have a previous cell along the body is the head. For this cell, we move the old head location in the direction of the last key press and make this the new head. A way to implement this in JavaScript is with the pop and unshift array methods. pop will remove and return the last element from an array, making the array one smaller. unshift is a strange name for inserting a new element at the front of the array, making the array one bigger.

If the snake is not growing, this pop and unshift can be used to move the snake in the current direction. To grow, you can perform an unshift and omit the pop over several frames to add multiple cells to the current snake.

Updating the snake body in the snake class does not update the grid labels in the world however, so you might be updating the Snake state internally, but the grid still says the snake is in the center of the grid. You will need to do some careful updating of the Snake state and the World state in the right order to get both in a consistent state.

8.2. Animating

If you update the snake’s status and the corresponding gameboard’s status through the world object each time you call drawScene the snake should move across the screen and respond to keypresses. You should only update the snake and the world if the game is not over (the snake is still alive) and the game is not paused.

You may find the default speed too fast (maybe not, you are all young). One way to slow things down is to only update if a given amount of time has elapsed. You can add something like the following in your drawScene. You will need to define lastUpdate and waitTime as globals, and pick a waitTime in fractions of second.

if (time - lastUpdate > waitTime){
  lastUpdate = time;
  //update snake, world
}

//draw even if no update

Once you get your event listeners set up and your snake class developed, you should be able to move the single pixel around the screen.

9. Adding game logic

Once you can control the basic snake element, it is time to finish the game logic. This will mostly happen in the World and Snake classes as you are just now responding to events. drawScene should know how to draw then scene and when to trigger an update of the game state, but the World and Snake classes will actually perform that update. The event listeners may modify the state too through pausing the game, or changing the direction of the snake. Again, the listeners will know when to trigger the update, but the classes will do the work.

9.1. Support for game ending

Your game should end when the snake runs into a wall or itself. At this point, you should stop animating and responding to arrow key presses or pause/unpause events. It is fine to restart the game through a page reload. Adding support through another way of restarting is an optional extension.

9.2. Support for growing

When you snake crosses over a food cell, grow the snake, remove the old food item, and drop a new food item in a random open location. This can be done by adding just a small amount of extra state to the Snake class and stretching the growing over several animation frames. The default is to grow 3 cells on each food item, but feel free to add extensions that grow proportional to the snake’s current length or some other metric.

9.3. Ignore 180 degree turns

If a snake is going up, and the user presses the down key, this would normally result in the snake running into itself and the game ending. Detect and ignore these events and only allow turns that are 90 degrees to the current direction.

10. Extending the basic game

A small portion of your grade is reserved for extensions to the basic game. There are numerous possibilities, some of which are listed below.

  • Gradually increasing the speed over time

  • Adding and updating a Score field somewhere on the canvas

  • Adding support for multiple levels, with different wall configurations

  • Allowing open edges where a snake can go through a gap on say the left end and appear on the right side.

  • Changing the growth amount from eating a food pellet to be non-uniform.

  • Other ideas?

11. Viewing Changes

If you make modifications to the files on the CS server and save them, you should be able to refresh the browser and see your changes without needing to restart the servers or ngrok tunnel. If it appears your changes are not visible in your browser, you can try a hard refresh.

12. Working in Partnership

When working with a partner, you may want to edit in one copy of the repo perhaps via Zoom or Live Share. Only one partner needs to set up the python and ngrok links and then share the ngrok url with the other partner.

Periodically, add, commit, and push your changes. Then your partner can pull your changes and you could switch roles as to who is hosting the server and ngrok session.

13. Closing the Web Server

When you are finished with a session, be sure to close your ngrok and python3 session. ngrok is likely running in the foreground and can be stopped using Ctrl-C.

You can stop all your python3 jobs using

pkill -u adanner python3

replacing adanner with your username.

14. Summary of Requirements

Your project will be graded on the following components:

  • Support for coordinate transforms from grid coordinate to clip coordinates.

  • Correct rendering of 2D grid world

  • Game playable according to basic logic rules

  • Some creative extension of the basic components

  • Answer to concept questions in the Readme.md file.

  • A small percentage of your grade will be based on style, and creativity. Have fun and explore.

You will not be graded on the lab survey questions in the Readme.md file

Submit

Once you have edited the files, you should publish your changes using add, commit and push.

The git push command sends your committed changes to the github server. If you do not run git push before the submission deadline, I will not see your changes, even if you have finished coding your solution in your local directory.

If you make changes to files after your push and want to share these changes, repeat the add, commit, push loop again to update the github server.

If you want to commit changes to files that have already been committed to git once, you can combine the add and commit steps using

$ git commit -am "bug fix/updates"

The -a flag will automatically add files that have been previously committed. It will not add new files. When in doubt, use git status, and please do not use git add * ./

Please do not add your symlink to the cs40lib folder. I have it set to be ignored, and it may create conflicts if partners are working on different personal computers.