Pongdate - two-player Pong built with the SDK

I was really impressed with @Drew-Lo's implementation of Pong using Pulp.

It works really well and you'd never know there was a tile-based engine underneath it all.

Now that the SDK is out though, I figured I'd try my hand at Pong using the SDK.

I'm an experienced developer, but very new to game development. So I'm going to use this as a learning experience in learning the Playdate SDK, basic game development fundamentals, graphics, etc.

This thread will follow my progress in case it's helpful to others learning Playdate SDK development.


Start with the Examples

Distributed with the SDK, there's a ton of great examples.
It's totally worth checking them all out. Many of them have inspired me, given me ideas, and generally taught me some basic concepts for developing games.

And for this particular project, I used code from the AccelerometerTest as well as the Single File Example balls.lua

Both are similar, and gave me some basic structure for a ball bouncing around..

A bouncing ball

The first thing I got working was a bouncing ball, implemented as a sprite.
bouncing ball

Since everything is a sprite, my playdate.update() loop is just:

function playdate.update()                                                      
  gfx.sprite.update()                                                           
end                                                                             

The ball sprite has some basic logic on draw, to make it flash filled when it collides with something:

ballSprite.draw = function()
  gfx.setColor(gfx.kColorBlack)

  if ballSprite.collided then
    gfx.fillCircleAtPoint(radius, radius, radius)
    ballSprite.collided = false
  else
    gfx.drawCircleAtPoint(radius, radius, radius)
  end
end

I then use a update function on the sprite, to have the logic of how the ball moves.
I'm not going to bother with all the code here, but there's basically some bounds checking to see if we've hit a wall, in which case we adjust the speeds (dx, dy) to account for bouncing off. Then we eventually do

ballSprite.moveTo(newX, newY)

to move the ball along.

Later I think this is where I'll need to deal with collisions with the paddles, as well as "goal" conditions, instead of bouncing off the end walls.

DPad Paddle

Next I added a paddle for one player, that will use the directional-pad (dpad) to control it. This is just another sprite, with a simple fillRect in the draw function.

The update for this sprite looks at whether the directional buttons are pressed, and if so, moves:

dpadPlayerSprite.update = function()
  if playdate.buttonIsPressed( playdate.kButtonUp ) then
    if dpadPlayerSprite.y > 20 then
      -- TODO could this be animated to be more smooth?
      -- would I need a higher framerate?
      dpadPlayerSprite:moveBy( 0, -DPAD_SPEED ) -- currently 10
    end
  elseif playdate.buttonIsPressed( playdate.kButtonDown ) then
    if dpadPlayerSprite.y < 220 then
      dpadPlayerSprite:moveBy( 0, DPAD_SPEED )
    end
  end
end

This feels pretty good to me, although I wonder if it could be more smooth... maybe the whole thing running at a higher framerate?

Crank Paddle

The other player will control their paddle with the crank.
I'm not really sure how well this will work as a control mechanism, but its fun to use the crank for stuff, and it seems cool to try to make a two-player, simultaneous-play, on one device.

crankPlayerSprite.update = function()
  local change = playdate.getCrankChange()
  local normalizedCrankInput = math.floor(change * CRANK_SCALE)
  normalizedCrankInput = math.min(MAX_CRANK_SPEED, normalizedCrankInput)
  normalizedCrankInput = math.max(-MAX_CRANK_SPEED, normalizedCrankInput)
  if crankPlayerSprite.y < 20 then
    normalizedCrankInput = math.max(0, normalizedCrankInput)
  elseif crankPlayerSprite.y > 220 then
    normalizedCrankInput = math.min(0, normalizedCrankInput)
  end
  crankPlayerSprite:moveBy( 0, normalizedCrankInput )
end

This update checks the amount the crank has changed, potentially scaling it by some scaling factor. I'm not sure if the degrees of rotation of the crank should map directly to pixels moved.... although so far in my testing that feels like it might work. But I think this will really need to be tuned once the hardware is in-hand.

We also normalize here to have a MAX_CRANK_SPEED. Originally I thought this should be the same as the increment moved by the directional pad – to keep it fair – i.e., both paddles would have the same maximum speed.

But in playing with it, it felt better with this max speed a bit higher.... again, something to tune when playing with the actual hardware.

Summary
paddles
So there you have it... that's where we're at after the first hour or so of writing this code :slight_smile:

Next step will be figuring out how the sprite collision system works, and using that for bouncing the ball off the paddles.

Feedback

I have two reason for writing this up...

  1. Maybe it helps others who are also learning this stuff
  2. To get feedback! I'd love for those more knowledgable on this stuff to give me some pointers of how I might do things better, cleaner, more efficiently, more elegantly, etc.

Thanks all!
I'm really having fun playing with this.

Here's the code (and a OS X compiled PDX):
Pongdate-0.0.1.zip (23.5 KB)

4 Likes

Bouncing off paddles - collision system

Ok – so I've started to play with collisions.

The first step is defining the collision rect for my sprites:

ballSprite:setCollideRect( 0, 0, ballSprite:getSize() )

I did this for the ball, and both player sprites. The above is a simple way to treat the whole sprite as the collision area.

Next... where should we deal with the collisions?

This is still kind of an open question to me, of what the best way to do this is.

In the balls.lua example, where there are lots of balls bouncing around colliding to each other, it checks for all collision pairs before updating the sprites:

function playdate.update()
	checkCollisions()
	gfx.sprite.update()
end

local function checkCollisions()
	local collisions = gfx.sprite.allOverlappingSprites()	
	for i = 1, #collisions do		
		local collisionPair = collisions[i]
		local sprite1 = collisionPair[1]
		local sprite2 = collisionPair[2]
		sprite1:collide(sprite2)
	end	
end

And then does a bunch of math for each sprite bouncing off another.

My case seemed simpler than this, so I looked at other examples.

In the collisions.lua example, they use moveWithCollisions
Given so far I'm mostly just concerned with the ball colliding with things, it seemed to make sense to just deal with collisions here, as I move the ball.

With more things moving around and more different things colliding into each other, maybe it'd make sense to do this another way... but this seems to make sense so far.

So I replaced my:

  ballSprite:moveTo(newx, newy)

with:

  local actualX, actualY, cols, cols_len = ballSprite:moveWithCollisions(newx, newy)
  for i=1, cols_len do
    local col = cols[i]
    -- not trying to be physics-accurate
    print(dx)
    if col.normal.x ~= 0 then -- hit something in the X direction
      dx = -dx * paddlebounce
    end
    if col.normal.y ~= 0 then -- hit something in the Y direction
      dy = -dy * paddlebounce
    end
  end

The mostly works and does what I expect. Just reversing the X or Y speed when bouncing off something in that direction.

I'm still not quite understanding the details of how moveWithCollisions works though... it seems maybe I should define:

playdate.graphics.sprite:collisionResponse(other)

for the sprites? Without adding this, it defaults to kCollisionTypeFreeze, which is probably not what I want.

I'm also confused as the documentation says that moveWithCollisions:

Moves the sprite towards goalPoint taking collisions into account, which means the sprite’s final position may not be the same as goalPoint .

But I don't quite get how you control how the collisions affect the goalPoint... maybe by defining those collisionResponse's? If someone understands this, please enlighten me.

I have a feeling figuring this stuff out will help me debug why this is happening:
freezeBounce

I have it speed up slightly every time it bounces off the paddles, and slow down slightly every time it bounces off a wall.

Inspecting the speed, I can see that it freezes when the speed is fast enough that it detects its hitting a wall:

  if newx < left and dx < 0
  then
    newx = left
    dx *= -wallbounce
    ballSprite.collided = true

This happens before I call moveWithCollisions... so I think what's happening is it's reversing the direction based on the wall bounce, but then reversing the direction again because of the paddle bounce. But because none of these get it out of a state of colliding, it's stuck in that position. It stays stuck while the speed slows down (because of the wall bounce) until it's slow enough that it no longer would have hit the wall from that position, and then it just bounces of the paddle.

So... how do I fix this? Curious if folks have ideas?

I'm thinking about trying to first mess with collisionResponse's and see if that changes anything. And then alternatively I'm thinking that I might just create sprites to represent the walls instead of the specific code detecting the walls, so the wall collisions are handled consistently with the other collision code.

Thoughts?

Thanks all!

PS... I've put this thing on github: GitHub - jestelle/pongdate: Pong, for the Playdate, built with the Playdate SDK

1 Like

I tried adding code like this for each sprite:

ballSprite.collisionResponse = function(other)
  return gfx.sprite.kCollisionTypeBounce
end

It did help – so I do think it's important to define this to be what you expect the behavior to be, when using moveWithCollision. However, it did't fix the problem... I still get weird behavior when the speed is faster that I can't explain yet:
betterBounceFreeze
But I think it's still the same basic problem of it's effectively bouncing off both the wall and the paddle – which shouldn't be possible :wink:

1 Like

This appears to be the right solution :wink:

And it's consistent with the approach taken in the collisions.lua example.

In fact, I borrowed this code from that example


class('Box').extends(playdate.graphics.sprite)
function Box:draw(x, y, width, height)
  local cx, cy, width, height = self:getCollideBounds()
  gfx.setColor(playdate.graphics.kColorWhite)
  gfx.fillRect(cx, cy, width, height)
  gfx.setColor(playdate.graphics.kColorBlack)
  gfx.drawRect(cx, cy, width, height)
end
local function addBlock(x,y,w,h)
  local block = Box()
  block:setSize(w, h)
  block:moveTo(x, y)
  block:setCenter(0,0)
  block:addSprite()
  block:setCollideRect(0,0,w,h)
end
-- border edges
local borderSize = 5
local displayWidth = playdate.display.getWidth()
local displayHeight = playdate.display.getHeight()
addBlock(0, 0, displayWidth, borderSize)
addBlock(0, borderSize, borderSize, displayHeight-borderSize*2)
addBlock(displayWidth-borderSize, borderSize, borderSize, displayHeight-borderSize*2)
addBlock(0, displayHeight-borderSize, displayWidth, borderSize)

Not only did it give me sprites on my boarders, it also demonstrated some basic object-oriented programming in Lua, which is good for me writing code like a real computer scientist :stuck_out_tongue:

I'll also note that I like how this doesn't hard code the device dimensions. Generally I've found my code has way too many "magic numbers" in it so far, and this demonstrates a better way to do things.

Next, I'm wondering if I can actually position these boarder sprites off screen, such that the sprite collision system can still be used for bouncing off the walls, but I don't have to render these on screen...

1 Like

Oh! Before I try to solve the problem of making these boarder sprites invisible, here's a funny capture of the ball speeding up and reaching an "escape velocity" where it appears to disappear.

escapeVelocity

Obviously I need to have a maximum velocity. :smiley:

4 Likes

Yup. This worked fine. Just adjusted these boxes to render just off screen.

Of course I could probably optimize by having them not draw anything either... but maybe I'll reuse this class for rendered things later? Dunno.