Hey Guys!
I figured a fun project to really dig into what's possible with Pulp would be to recreate the all-time classic Pong. My goal was to make it run with a smoothness that you wouldn't think would be possible with Pulp at first glance
If you don't want to hear my blabber about PulpScript, then you can just download the game here and enjoy!
Pulp Pong v2.41.zip (59.6 KB)
If you're wondering how this is possible to do in Pulp, then read on my friends!
The Room
The graphics and room setup is super simple: I have just a single room consisting of a canvas of non-solid black tiles, two paddles and a ball:
You'll notice the vertical red lines on the left side: These are solid black tiles, which are there to make sure that the player can only move vertically. If these weren't there then the player could use the D-pad to move the paddle all over the screen.
The score
You'll notice the score at the top of the screen. This is the easiest of the on-screen elements to manage. Originally I used an item tile for each score, and each number was a different frame
This worked just fine, but i realized that i could simplify the logic and do this much easier just by using the label
function in the player's draw
event to display the score:
on draw do
// Display score
label "{p1_score}" at 11,1
label "{p2_score}" at 13,1
end
(Note: you can only use label
in the pulp script for player
)
Finally i just needed to update the numbers in the game's font to make them look more retro-y
Alright, now our score is ready to go! Time to move on to the more complicated stuff
The Player
The different players control very differently. We'll start with player 1:
Player 1
in Pulp, you just plop the player tile down in the room and it moves automatically when the user hits the D-pad, so that's easy enough. I drew the solid black columns on either side of the player to keep him from moving forward and backwards and that's all i need to get started.
Now comes the challenge of how to achieve smooth animation? In pulp, if you hit the D-pad the player sprite jumps by an entire tile. I want to create the illusion of the paddle moving pixel by pixel as it would in another game framework, so I decided to use some smoke and mirrors to achieve this effect.
First i'm going to effectively extend the size of the player paddle by adding another tile above and below the player
// player PulpScript for Pulp Pong
on enter do
bottomY = event.py
bottomY += 1
topY = event.py
topY -= 1
// draw top and bottom paddles when game begins
tell event.px,topY to
swap "player1_top"
end
tell event.px,bottomY to
swap "player1_bottom"
end
end
This adds the top and bottom paddle tiles at the beginning of the game, but i also need to add them anytime the player moves. I wanted to take things a step further though and add some fluid animation for the paddle.
I put the logic into a function called animate_paddles
which is called anytime the player moves. Essentially I'm adding animated top and bottom tiles that make it look like the paddle is smoothly moving (via the play
function, and after the animation is completed, i replace the animated tiles with static ones.
Depending on the direction the player paddle is moving, i define the animations i want to use, since I need different animations for moving up vs moving down:
// load the correct animations depending on direction of paddle
if event.dy>0 then
top_paddle_animation = "P1_top_down"
bottom_paddle_animation = "P1_bottom_down"
call "animate_paddles"
elseif event.dy<0 then
top_paddle_animation = "P1_top_up"
bottom_paddle_animation = "P1_bottom_up"
call "animate_paddles"
end
After defining the animation, i call animate_paddles
to play the animation, then subsequently replace it with the static top/bottom tiles. Also note that I'm erasing the spot where the top or bottom tiles used to be by replacing them with an empty black tile (swap "black"
)
on animate_paddles do
if topY>=0 then
tell event.px,topY to
play top_paddle_animation then
current_top = event.py
current_top -= 1
// after animation has played, replace with static player1_top if paddle hasn't moved further
if topY==current_top then
swap "player1_top"
// otherwise erase the tile where the animation just played
else
square_type = type event.px,current_top
if square_type!="sprite" then // make sure we don't erase the ball
swap "black"
end
end
end
end
end
if bottomY<=14 then
tell event.px,bottomY to
// swap bottom_paddle_animation
play bottom_paddle_animation then
current_bottom = event.py
current_bottom += 1
if bottomY==current_bottom then
swap "player1_bottom"
else
square_type = type event.px,current_bottom
if square_type!="sprite" then // make sure we don't erase the ball
swap "black"
end
end
end
end
end
end
Here's what it looks like in action:
One more thing that i should add: To make it animate smoothly even if the player is holding the D-pad down, i fiddled with these parameters to make sure that the paddle doesn't just teleport from one spot to the next:
config.inputRepeatDelay = 0.1
config.inputRepeatBetween = 0.1
So now the first player's paddle is ready to go! Let's take a look at the next one.
Player 2 (CPU)
Unlike the player
sprite which Pulp moves for you when the D-pad is pressed, we need to handle player 2 a bit differently. Unlike the player sprite, which jumps forward by an entire tile when the user hits the D-pad, any other sprite i can track with a much finer granularity. I track player 2's y position on a scale from 0-60, 0 being the top edge of the screen, and 60 being the bottom edge. Why 60? well Pulp's tile grid is 15 tiles high, and i want to give the paddle 4 sub-positions within each tile.
Player 2 is also 3 tiles high, and the middle tile is simple sprite tile with a vertical line. The top and bottom sprites however, have 4 frames, each representing a more granular position within the tile:
Note that fps
(used to control the frames per second at which the animation should play) is set to zero. When this is set to zero, you can set the animation manually using the frame
command.
CPU_y += CPU_dy
P2_position = CPU_y
P2_position /= 4 // divide by 4 to get relative tile position
P2_tile = floor P2_position // remove decimals to get exact tile number
// calculate frame for more granular movement
P2_frame = P2_position
P2_frame -= P2_tile
P2_frame *= 4
P2_frame = floor P2_frame
(CPU_dy is set to a constant value which is the speed of player 2's paddle.)
Now to move player 2, we just erase the sprites from the previous position with swap "black"
, place the paddle sprites around the new P2_position
, and finally set the sub-tile position with frame P2_frame
// CLEAN UP PREVIOUS PADDLE TILES:
// erase center of paddle
call "check_collision"
tell p2_x,P2_previous to
swap "black"
end
// erase bottom paddle
P2_previous += 1
if P2_previous<=14 then
call "check_collision"
tell p2_x,P2_previous to
swap "black"
end
end
// erase top paddle
P2_previous -= 2
if P2_previous>=0 then
call "check_collision"
tell p2_x,P2_previous to
swap "black"
end
end
P2_previous = P2_tile // store this so we can erase it on next move
// move player 2 to new position
call "check_collision"
tell p2_x,P2_tile to
swap "player2_CPU"
end
if p2_top>=0 then
tell p2_x,p2_top to
swap "P2_top"
frame P2_frame
end
end
if p2_bottom<=14 then
tell p2_x,p2_bottom to
swap "P2_bottom"
frame P2_frame
end
end
Of course there's a lot of error checking and a bunch of Pulp text covering various corner cases, but I won't get into that here. The AI is pretty straightforward as well, since it just moves the paddle in the direction of the ball. If you're interested in the specifics, i've attached the game's JSON, so feel free to load it into Pulp and take a look for yourself!
Player 2 (crank-controlled for local multiplayer!)
Since this Pong, I needed to make it playable for 2 players! Since the playdate has a beautiful crank, we can use this so another player can control the second paddle.
Here is what the main loop in the "Game" pulp script looks like:
on loop do
// move ball every frame
tell ball_x,ball_y to
call "update_ball"
end
// move player2 if CPU controlled
if CPU_control==1 then
tell "player2_CPU" to
call "update_P2"
end
end
end
on reset_score do
p1_score = 0
p2_score = 0
end
You'll notice that i have a variable CPU_control
, that when set to one will trigger the update_P2
function in PulpScript for player2_CPU
. Here was my idea: If we see that the crank has been moved, we set this CPU_control
to zero, and swap out the computer AI for the crank inputs to control player 2.
This is really do do in the crank event, which lives in the PulpScript for player
and is called whenever the crank is turned:
on crank do
// if P2 is CPU controlled, switch to crank-controlled player 2 tile
if CPU_control==1 then
CPU_control = 0
// set "P2_previous" so that player2 tile will erase previous paddle graphic
P2_previous = P2_tile
// tell player2_CPU to swap to player-controlled P2 tile
tell p2_x,P2_tile to
swap "player2"
end
// if player 2 is already player-controlled, call P2's update function
else
tell "player2" to
call "update_P2"
end
end
end
So the first time the crank is turned, it'll set CPU_control
to zero, and swap out the player2_CPU
sprite (which contains the computer AI) with the player2
sprite, which controls the logic to move the right paddle based on the crank position.
Now YOU'RE in control! (sorry that was really corny, just ignore that).
For the crank control, I want it to feel like you're controlling the paddle directly with the crank, so having the crank in its vertical position at 12:00 should put the paddle up against the top of the screen, and having the crank downwards towards 6:00 should put the paddle on the bottom of the screen. Since I'm mapping the paddle directly to the crank position, the animation will already be fluid, so there's no need for any smoke and mirrors with animations this time around!
Let's take a look at the update_P2
function that's called whenever the crank is moved:
on update_P2 do
// get angle between 0 - 180:
crank_angle = event.aa
if event.aa>180 then
crank_angle = 360
crank_angle -= event.aa
end
P2_position = crank_angle
P2_position /= 12.1 // divide by 12 to convert angle into tile position (.1 ensures we'll always round down to 14)
P2_tile = floor P2_position // remove decimals to get the tile number
// calculate frame from remainder of tile calculation for more granular movement
P2_frame = P2_position
P2_frame -= P2_tile
P2_frame *= 4
P2_frame = floor P2_frame
// move player 2
call "player2_move_tile"
end
First i'm making sure if that crank_angle
is never above 180, then i'm going to translate this value to which tile it should be on by dividing by 12 and truncating the everything past the decimal point. Next I take what's left behind the decimal point (which should be between 0 and 1) and multiply it by 4 to get the sub-tile value, which we'll use to set the frame of our sprite like we did with the paddle sprites before.
The player2_move_tile
function then will move the paddle to the new position and update the frames pretty much identical to what I did in the player2_CPU PulpScript:
on player2_move_tile do
// FIRST ERASE PADDLE FROM PREVIOUS POSITION
// erase center of paddle
call "check_collision"
tell p2_x,P2_previous to
swap "black"
end
// erase bottom paddle
P2_previous += 1
if P2_previous<=14 then
call "check_collision"
tell p2_x,P2_previous to
swap "black"
end
end
// erase top paddle
P2_previous -= 2
if P2_previous>=0 then
call "check_collision"
tell p2_x,P2_previous to
swap "black"
end
end
P2_previous = P2_tile // store this so we can erase it on next move
// calculate updated p2 positions
p2_top = P2_tile
p2_top -= 1
p2_bottom = P2_tile
p2_bottom += 1
// move player 2 to new position
call "check_collision"
tell p2_x,P2_tile to
swap "player2"
end
if p2_top>=0 then
tell p2_x,p2_top to
swap "P2_top"
frame P2_frame
end
end
if p2_bottom<=14 then
tell p2_x,p2_bottom to
swap "P2_bottom"
frame P2_frame
end
end
end
Check out the resulting smooth and responsive paddle action!
The Ball
This was probably the trickiest bit. By now you're already familiar with the tricks i used to make objects appear to move smoothly in Pulp, but the ball has some extra challenges. Unlike the paddles that only move up or down, the ball can go in 4 different directions. To manage this, the ball you see is actually 4 different ball$:
If you take a look at the sprite in this screenshot, you'll notice that the one highlighted is called
ball_UR
, and has 4 animation frames. Similar to the paddles, i get a smooth-looking animation by moving the ball (at its slowest speed) one frame at a time. Depending on which direction the ball is traveling in, I swap out the different versions of the ball's sprite. You may also notice the PulpScript in the bottom right is very short.
on any do
mimic "ball"
end
All 4 of the ball variants have the same short snippet of PulpScript, which just tells them to follow the logic that's in the main ball
sprite. This way, i can put all of the ball logic in one place, and have it executed no mater which ball variant is on the screen!
Let's dive in to the ball logic then:
on enter do
ball_x = event.x
ball_y = event.y
last_tile = "black"
ball_speed = 1
// initialize initial (random) trajectory of ball
rand_num = random 1
if rand_num==1 then
ball_dx = 1
else
ball_dx = -1
end
rand_num = random 1
if rand_num==1 then
ball_dy = 1
else
ball_dy = -1
end
call "update_ball_sprite"
end
This is called on the ball when the game starts. It generates a random trajectory for the ball, then calls the update_ball_sprite
function which will swap in the appropriate ball tile:
on update_ball_sprite do
// swap the ball sprite given dx and dy values
if ball_dx>0 then
if ball_dy>0 then
swap "ball_DR"
else
swap "ball_UR"
end
elseif ball_dx<0 then
if ball_dy>0 then
swap "ball_DL"
else
swap "ball_UL"
end
end
end
Now we need the logic to animate the ball, which is pretty straightforward. We call the update_ball
function once every frame, where we increment the frame counter by "ball_speed", which starts at 1 and increases as the game goes on. Basically the ball has 4 frames (frames 0 - 3), and we move it one frame at a time. Once we've gone past the last frame, we reset the frame counter and move the ball to the next tile via the move_ball
function:
on update_ball do
// update ball frame
ball_frame += floor ball_speed
// if we've gone through all frames in the animation, reset ball_frame to 0 and move ball
if ball_frame>3 then
ball_frame = 0
call "move_ball"
end
frame ball_frame
end
I didn't go into the nitty gritty of the details of collision detection, sound effects and covering various corner cases, but the Pulp JSON is attached to this post, so feel free to take a look at the nuts and bolts if you're curious on how it's done.
I hope you guys are enjoying playing around with Pulp as much as I am, and hopefully this post will inspire you to level up your own Pulp game!
-Drew
P.S. if you're wondering why i wrote the plural of ball as "ball$", it's because the original is apparently flagged as a bad word
ballz