Pulp Pong - Dev Tutorial

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

PulpPong2

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
Screen Shot 2022-01-20 at 11.27.22 AM

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.
2022-01-20 17.08.08

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:
paddle_up_and_down

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!
crank_controlled

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

33 Likes

This is all so clever. Thanks for the write up! Yesterday I felt like I got a handle on the GUI tools but I’d prefer to write a little more code. This is exactly what I needed to unlock a new level of Pulp development.

4 Likes

Really cool! I haven't tried Pulp yet, but it seems like the truly impressive part of this is making a not at all tile-based game in a tile-based game engine :stuck_out_tongue:

I noticed something:

If you want P2 to be directly controlled by the crank's physical vertical position, then I think you should be taking P2_position = cos(crank_angle), which would also change your tile calculations considerably. I'd be curious how it affects game feel, can't wait to get my hands on one and find out!

Haha, thanks! Yeah, my whole motivation to make this was to create something that Pulp isn't really meant to do. You're right about using cosine, being more accurate for the crank position, but there isn't a cos function in Pulp that I know of. I'd have to implement a really clunky lookup table to calculate it, or an even worse idea, write a function that calculates a power series. I think in this case just using a linear scale is good enough :sweat_smile:

Oh, huh! I'm surprised a little trig isn't in there :thinking:. Yeah, I definitely agree writing your own cos for a maybe imperceptible change is not a good idea, haha.

Great tutorial, @Drew-Lo, thanks for sharing!

I guess the trigonometry stuff will become available with the Lua and C SDKs which will hopefully also make it possible to create non-tile-based games without the hacking. That’s what I hope at least.

1 Like

Oh yeah, all that stuff is trivial with the SDK of course! Here's a gif of something I was messing around with which is programmed in Lua. Moving the player around and calculating the bullet angles is no problem whatsoever:
cranktv3

With the SDK you can make anything you can imagine. Pulp isn't made for action games, I just did this as a fun little challenge :slight_smile:

4 Likes

Oh, can’t wait for the SDK! Inspiring how you hacked your way to a working Pong in the meantime.

2 Likes

I have a few questions about your code, so I was using the code from your screenshots and I get some errors when I coded this: // 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
And I get an error saying that “expected event declaration on line 2” and the same for the code for player 2 (shown below):
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

And I get the “expected event declaration on line 1” error message just like the other code when I was coding the player 1.

Is their a way to find a solution to this because I can’t continue making the player 1 and player 2 paddles without solving this error message in the code.

Thank you.

This is pretty sweet! I have a pipe dream to make a narrative Pong-style Game loosely based on the Mesoamerican ballgame. I may have to steal some of this or try to bribe you to collaborate.

1 Like

Haha, feel free to borrow anything you want from my Pong game, and feel free to reach out to me if there's anything i can do to help make your game happen!

I have a few questions about your code, so I was using the code from your screenshots and I get some errors when I coded this: // 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
And I get an error saying that “expected event declaration on line 2” and the same for the code for player 2 (shown below):
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

And I get the “expected event declaration on line 1” error message just like the other code when I was coding the player 1.

Is their a way to find a solution to this because I can’t continue making the player 1 and player 2 paddles without solving this error message in the code.

Thank you.

All your code needs to be inside event handlers - that's what the error message means.

If you just type some code, pulp doesn't know when you want that code to run. An event declaration tells pulp in what situation the code should run.

For example:

on interact do
  say "Hello!"
end

This tells pulp that it should run the code say "Hello!" when the event called "interact" is triggered. Different events are triggered in different situations. For a sprite, the "interact" event is triggered by the player walking into a sprite. In our example the sprite will react to being walked into by saying "Hello!".

If you just typed

say "Hello!"

directly into the script for the sprite, pulp doesn't know when it is supposed to run that code.

In other words, all of your code needs to be inside blocks that start with on someEvent do like:

on someEvent do
  // Your code
end

Then how come in his code it doesn’t show an error message for him and he didn’t do the event declaration. I used the exact same code he did. Idk maybe I’m try what you said and maybe it will workout.

No I’m sorry it still does the same thing. Can you help me out with this please?

wow. just wow. great job i could never have done this.