Homework 12a
Due Date Monday 11/25 at 9:00pm (Week 10)
Purpose To enhance QBert with two-player support
Starter code: server code
Graded Exercises
In the previous part of QBert, you implemented the logic for stomping blocks, enemies, finishing levels, and making the game playable. In this week’s exciting conclusion, you’ll add support for a second player. The overall goal is for two players to connect to a server and be paired up. The game will now feature two players, QBert and RBert, that can move independently. But letting both players independently move enemies and update the board will lead to a mess, so in the software, we’ll distinguish QBert and RBert from the “Player 1” role and the “Player 2” role. One player’s computer will be Player 1, and it will be responsible for creating and moving the enemies around, moving QBert and RBert around stomping on blocks, etc. The other player’s computer will be Player 2. Player 2 will notify Player 1 whenever they move RBert; Player 1 will in turn notify Player 2 every time the world gets updated. Each player will control their own character, and be able to move them independently.
In this design, we will provide you with code that implements the server-side logic of pairing up two players into a game together. When you start your program, you won’t know whether you’re going to be Player 1 or Player 2 (it depends on how many other players are available!), so once the server has matched you with a partner, it will inform you of which player you are, and your code will react accordingly. We’ll give step-by-step instructions, below. Read through the whole assignment, to get a sense of what you have to do, before you start coding. There are a lot of interrelated data definitions needed here, so it’s easy to get confused.
Exercise 1 You and your partner have two distinct implementations of QBert so far. Combine the best aspects of both of them: choose the cleaner data definitions, cleaner functions, more thorough testing, etc., and produce a new implementation of QBert part 2 that is better than either of your original starting points. Incorporate any feedback you received from your graders on the prior parts: if we noted that something is a poor implementation choice, you should fix it. Since this is the final part of this project, you will be graded cumulatively on all of it: prior mistakes can be penalized again.
Once you’ve merged your code and cleaned up any prior issues, you can add support for a second player, in several steps. Be sure to work through these in sequence, so that you don’t have incomplete and hard-to-debug programs along the way.
Initial steps:
- Renaming: Both of your players will have their own world state, and their own big-bang event handlers with their own logic. In preparation, you should rename your existing functions. Presumably, you currently have a big-bang call that looks roughly like
(big-bang (make-qbert-level ...various world info...) [on-tick tick-handler] [to-draw draw-world] [on-key key-handler] ...) Rename all your functions with a /p1 suffix to their names, so that your big-bang call looks like this: - Organization: Your program will have four sections of related content. Add comments to your code to separate it into the following four sections:
; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ;;;;;; Shared data definitions and functions ;;;;;; ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ;;;;; Player 1 data definitions and functions ;;;;; ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ;;;;; Player 2 data definitions and functions ;;;;; ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; ;; Player-neutral data definitions and functions ;; ; ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; Most of your existing code should currently go in the Player 1 section, but as you add new features, or generalize existing code, move it to the appropriate section. Put all of the relevant code (data definitions, templates, examples, functions, test, comments) in the relevant sections, to make it easier for both you and your graders to see which code belongs where. Supporting two kinds of players: When you connect your game to the server, you won’t know yet whether you’re going to be Player 1 or Player 2. So we need to create a new, more general big-bang world state, to account for the possibility of being Player 1, Player 2, or not-yet-playing.
We will also need a new data definition to describe the results of our event handlers (which could be world states or packages of world states and messages):
; A World2Players is one of ; - 'not-playing-yet ; - (make-player1 Player1WorldState) ; - (make-player2 Player2WorldState) ; INTERPRETATION: This keeps track of whether you've been connected with a ; partner to play with, and whether you're Player 1 or Player 2 (define-struct player1 [world]) (define-struct player2 [world]) ; A Player1Result is one of ; - (make-player1 Player1WorldState) ; - (make-package (make-player1 Player1WorldState) Player1Message) ; INTERPRETATION: An event handler for Player 1 could either return a world ; or a world plus some messages to send to Player 2 ; A Player2Result is one of ; - (make-player2 Player2WorldState) ; - (make-package (make-player2 Player2WorldState) Player2Message) ; INTERPRETATION: An event handler for Player 2 could either return a world ; or a world plus some messages to send to Player 1 ; A World2PlayersResult is one of ; - 'not-playing-yet ; - Player1Result ; - Player2Result Note the template for a World2Players: it has three cases, for whether you’re waiting for a partner, whether you’re Player 1, or whether you’re Player 2.
For each big-bang handler you have, implement a new handler that takes in a World2Players, and chooses what to do accordingly. Here are the beginnings of two such handlers, to get you started:
; draw-world : World2Players -> Image ; Renders the world, depending on which player is playing (define (draw-world w) (cond [(symbol? w) (...draw something indicating you're not playing yet...)] [(player1? w) (draw-world/p1 (player1-world w))] [(player2? w) (error "Not yet implemented drawing for Player 2!")])) ; key-handler : World2Players KeyEvent -> World2PlayersResult ; Handles a key event, depending on which player is playing (define (key-handler w key) (cond [(symbol? w) w] ; nothing to do until you connect [(player1? w) (key-handler/p1 (player1-world w) key)] [(player2? w) (error "Not yet implemented key handling for Player 2!")])) Note that you’ll have to revise your Player 1 functions’ return values just slightly: instead of returning a (make-qbert-level ...), you’ll now need to return a (make-player1 (make-qbert-level ...)).
If you update your call to big-bang, your game should play exactly the same as before. This a good thing! It means you’ve refactored your code in preparation for adding support for Player 2.(big-bang (make-player1 (make-qbert-level ...various world info...)) [on-tick tick-handler] [to-draw draw-world] [on-key key-handler] ...) Note: You will need to create three receive-handler functions, for Player 1, for Player 2, and a general one for the beginning of the game. We’ll come back to these later in the assignment.
Extending your Worlds: Player 2 will add a new player, but everything else will stay the same. Add a new field to your QBertLevel struct, so that you now have two fields for players. You’ll need to decide where to start the two players, and decide whether QBert and RBert are allowed to collide on the same block. (We’ll call this enhanced struct a Player1WorldState, in the data definitions below.)
Enhance your draw-world/p1 function to draw both players onto your game board. QBert will be drawn the same as before; RBert will be drawn as or . Draw some marking around your board to indicate whether you are QBert or RBert —
e.g. an orange or blue border surrounding the image. (Note: I strongly recommend that you split this into two steps — drawing the board, and drawing the colored border — so that you can reuse this code more easily when you get to player 2 below...) Moving players, stomping blocks, moving enemies: Only Player 1 is responsible for moving the players around the board, updating the blocks, the score, and the enemies. Currently, RBert cannot move yet (since you’re implementing the various handlers for Player 1 so far, and we don’t yet have handlers for Player 2, or any way for Player 2 to send messages to Player 1...), but enemies can still collide with it. If an enemy collides with either QBert or RBert, or if either player falls off the board, the game ends.
Player 2
Player 2’s logic is much simpler than Player 1’s, so you’ve finished most of the hard parts already! Design Player 2’s handlers (key-handler/p2, tick-handler/p2, etc.) as follows:
Design a Player2WorldState. It should either be #false, to indicate Player 1 hasn’t sent any information to Player 2 yet, or it should be a struct that contains a Player1WorldState (which will contain all the current information about the game that Player 1 has sent to Player 2). If there is any other information that’s unique to Player 2, it should be stored in this struct as well.
Design a function draw-world/p2 that draws the gameboard from Player 2’s perspective: it should be nearly identical to draw-world/p1, except for the border color indicating that Player 2 is controlling RBert.
Player 2 doesn’t need a tick-handler/p2 handler to do very much (since Player 1 will do all the computation), so just return the world unchanged.
- Player 2’s key-handler should send a message to Player 1 with the direction RBert is trying to move. We’ll also give RBert the ability to jump two cells in any direction, rather than only move one cell at a time. The messages should look like
Design a function receive-handler/p2 that takes in a Player2WorldState and a Player1Message, and produces a new Player2Result for Player 2. A Player1Message will be a message that Player 1 sends to Player 2, explaining the current state of the game:
; A Player1Message is a ; (list 'update Player1WorldStateSexp) ; A Player1WorldStateSexp is a ; (list 'world ; QBPosnSexp – QBert's position ; Direction – QBert's direction ; QBPosnSexp – RBert's position ; Direction – RBert's direction ; Natural – number of steps taken ; BoardSexp – the current contents of the board ; EnemiesSexp – the current list of enemies on the board ; ) ; A QBPosnSexp is a (list 'qbposn Natural Natural) ; A BoardSexp is a [List-of [List-of CellSexp]] ; A CellSexp is one of ; - (list 'countdown Nat) – a countdown block and its current state ; - (list 'cycling Nat Nat) – a cycling block and its current and initial state ; An EnemiesSexp is a [List-of EnemySexp] ; An EnemySexp is one of ; - (list 'wanderer QBPosnSexp) – a wanderer and its current position ; - (list 'fixer QBPosnSexp) – a fixer and its current position ; INTERPRETATIONS: Each of these definitions corresponds to the ; non-s-expression data definitions from Homework 8a, including the new ; information added in that assignment. You will need to design a whole bunch of helper functions that convert these s-expression messages into world states. Here is some starter code:
; wssexp->world : Player1WorldStateSexp -> Player1WorldState ; Converts a description of a world state into an actual world-state (define (wssexp->world wss) (make-qbert-level (qbposnsexp->qbposn (second wss)) ; QBert's position (third wss) ; QBert's direction (qbposnsexp->qbposn (fourth wss)) ; RBert's position (fifth wss) ; RBert's direction (sixth wss) ; score (boardsexp->board (seventh wss)) ; convert the board contents (enemiessexp->enemies (eigth wss)))) ; convert the enemies ; qbposnsexp->qbposn : QBPosnSexp -> QBPosn ; Converts a description of a posn into an actual posn (define (qbposnsexp->qbposn p) (make-qbposn (second p) (third p))) ; Define more functions for all the other data definitions you need to convert Once you have converted the message into a Player1WorldState, that should replace the existing world state in Player 2’s Player2WorldState.
Finishing Player 1: Design a bunch of functions that convert Player 1’s world state into an s-expression, so that you can send messages to Player 2 informing them of how the game is being played. Here is some starter code:
Hint: it’s probably a good idea to test these functions together with the ones above, as a round-trip test: (check-expect (wssexp->world (world->wssexp some-world)) some-world), and similar tests, help check whether these functions are inverses of each other.
; world->wssexp : Player1WorldState -> Player1WorldStateSexp ; Converts a world state into a description of a world-state (define (world->wssexp w) (list 'world (qbposn->qbposnsexp (qbert-level-qbert-pos w)) (qbert-level-qbert-dir w) (qbposn->qbposnsexp (qbert-level-rbert-pos w)) (qbert-level-rbert-dir w) (qbert-level-step-count w) (board->boardsexp (qbert-level-board w)) (enemies->enemiessexp (qbert-level-enemies w)))) ; qbposn->qbposnsexp : QBPosn -> QBPosnSexp ; Converts a QBPosn into a description of a posn (define (qbposn->qbposnsexp p) (list 'qbposn (qbposn-row p) (qbposn-col p))) ; Define more functions for walls, regions, velocities, etc.
Update your tick-handler/p1 function so that on every tick, it sends the
current game state (encoded as a Player1Message!) to Player 2 —
Design a receive-handler/p1 function that takes in messages sent from Player 2, and updates its own world state as appropriate.
Putting it all together! At long last, you’re ready to put both players together. Download this starter code (right click > Save As...), which gives you our implementation of the server, and save it in the same directory as your homework file. At the top of your file, add the line (require "qbertServer.rkt") to include that code.
Complete the design of a receive-handler function for the outermost big-bang. It will take in a World2Players and a ServerMessage, and produce a World2PlayersResult:
; A ServerMessage is one of ; - "p1" ; - "p2" ; - Player1Message ; - Player2Message ; - '(invalid) ; INTERPRETATION: One of the first two messages are sent by the server to each ; client, exactly one time, to inform them whether they will be Player 1 ; or Player 2. Subsequent player-specific messages are sent during the game. ; The server may also send back '(invalid) in response to invalid messages ; that were sent by either player. ; receive-handler : World2Players ServerMessage -> World2PlayersResult ; Handles incoming messages from the server by either creating an appropriate player ; or forwarding the message to the player-specific receive-handler functions. (define (receive-handler w msg) (cond [(symbol? w) (cond [(string=? msg "p1") ... create an initial player 1 world state...] [(string=? msg "p2") ... create an initial player 2 world state...])] [(player1? w) ...] [(player2? w) ...]))
(define PORT 10001) (define (start-game playername servername) (big-bang 'not-playing-yet [on-tick tick-handler] [to-draw draw-world] [on-key key-handler] ...whatever else you need... [register servername] [port PORT] [name playername]))
At the very bottom of your file, add the lines
(launch-many-worlds (server "S") (start-game "Ben" LOCALHOST) (start-game "Amal" LOCALHOST))
This will start up a local server for you, and start two games (with player names "Ben" and "Amal"). One of the games will be Player 1, and one will be Player 2. Once you’ve gotten both your players working, you can try to connect to our server and play a real two-player game. Make two changes: first, comment out (server "S"), since you’ll be using our server instead of yours. Second, replace both uses of LOCALHOST with the string "linux-071.khoury.northeastern.edu", and you should be able to connect to our server which will match you up with a classmate to play against.
Have fun!