Pong

Warning

This example assumes familiarity with Starlight's API, as well as with Telescope. It is more focused on describing how to use these libraries than on what they do.

This example will walk you through the source code of Pong as implemented in Starlight's example subdirectory.

First off, the obligatory

using Starlight

Then, since the maintainer hates hardcoding anything, there are a lot of configurable parameters. You can play around with these if you download the source code. Their usage should make sense once you see them in context. If you forget what one is or what its default value is, or just want to know where it's used, Ctrl-F is your friend. :)

const window_width = 600
const window_height = 400
const paddle_width = 10
const paddle_height = 60
const ball_width = 10
const ball_height = 10
const wall_height = 10
const goal_width = 10
const hz = 1

# collision margins
const wmx = 0 # wall
const wmy = 0
const gmx = 0 # goal
const gmy = 0
const pmx = 0 # paddle
const pmy = 0
const bmx = 0 # ball
const bmy = 0

const pv = window_height # paddle velocity
const ball_vel_x_mult = 0.25
const ball_vel_y_mult = 1.5
const ball_vel_x = ball_vel_x_mult * window_width
const ball_vel_y_max = ball_vel_y_mult * window_height
const paddle_ball_x_tolerance = 2
ball_vel_y(i) =  -i * ball_vel_y_max
const score_scale = 10
const score_y_offset = 50
const msg_scale = 2
const center_line_dash_w = 10
const center_line_dash_h = 40
const center_line_dash_spacing = 20
const asset_base = joinpath(artifact"test", "test")
const secs_between_rounds = 2
const score_to_win = 10

Create an App with the given settings, and make sure to set the background to black instead of using the default grey:

a = App(; wdth=window_width, hght=window_height, bgrd=colorant"black")

We can now begin defining the static UI elements, like the centerline and scores, i.e. things that don't require their own message handlers.

We can start with the center line, which is an array of ColorRects evenly spaced between the two walls:

center_line = []
for i in (wall_height + center_line_dash_spacing):\
  (center_line_dash_h + center_line_dash_spacing):\
  (window_height - wall_height - center_line_dash_h - center_line_dash_spacing)
  push!(center_line, ColorRect(center_line_dash_w, center_line_dash_h; 
  color=colorant"grey", pos=XYZ((window_width - center_line_dash_w) / 2, i)))
end

Next we'll use Julia's Artifacts system to pull in a couple of public-domain spritesheets that we'll be using for text. But first, if we want to define things in terms of text strings, and since we're not using a standard format like TTF, we need to define what characters belong to which cells on the spritesheet. Feel free to just scroll past this, it's only included for the sake of completeness, normally such things would be handled by an asset importer or something, but we don't have anything like that for Starlight yet.

cpchars = Dict(
  ' ' => [0,0],
  '!' => [0,1],
  '\"' => [0,2],
  '#' => [0,3],
  '$' => [0,4],
  '%' => [0,5],
  '&' => [0,6],
  '\'' => [0,7],
  '(' => [0,8],
  ')' => [0,9],
  '*' => [0,10],
  '+' => [0,11],
  ',' => [0,12],
  '-' => [0,13],
  '.' => [0,14],
  '/' => [0,15],
  '0' => [0,16],
  '1' => [0,17],
  '2' => [1,0],
  '3' => [1,1],
  '4' => [1,2],
  '5' => [1,3],
  '6' => [1,4],
  '7' => [1,5],
  '8' => [1,6],
  '9' => [1,7],
  ':' => [1,8],
  ';' => [1,9],
  '<' => [1,10],
  '=' => [1,11],
  '>' => [1,12],
  '?' => [1,13],
  '@' => [1,14],
  'A' => [1,15],
  'B' => [1,16],
  'C' => [1,17],
  'D' => [2,0],
  'E' => [2,1],
  'F' => [2,2],
  'G' => [2,3],
  'H' => [2,4],
  'I' => [2,5],
  'J' => [2,6],
  'K' => [2,7],
  'L' => [2,8],
  'M' => [2,9],
  'N' => [2,10],
  'O' => [2,11],
  'P' => [2,12],
  'Q' => [2,13],
  'R' => [2,14],
  'S' => [2,15],
  'T' => [2,16],
  'U' => [2,17],
  'V' => [3,0],
  'W' => [3,1],
  'X' => [3,2],
  'Y' => [3,3],
  'Z' => [3,4],
  '[' => [3,5],
  '\\' => [3,6],
  ']' => [3,7],
  '^' => [3,8],
  '_' => [3,9],
  '`' => [3,10],
  'a' => [3,11],
  'b' => [3,12],
  'c' => [3,13],
  'd' => [3,14],
  'e' => [3,15],
  'f' => [3,16],
  'g' => [3,17],
  'h' => [4,0],
  'i' => [4,1],
  'j' => [4,2],
  'k' => [4,3],
  'l' => [4,4],
  'm' => [4,5],
  'n' => [4,6],
  'o' => [4,7],
  'p' => [4,8],
  'q' => [4,9],
  'r' => [4,10],
  's' => [4,11],
  't' => [4,12],
  'u' => [4,13],
  'v' => [4,14],
  'w' => [4,15],
  'x' => [4,16],
  'y' => [4,17],
  'z' => [5,0],
  '{' => [5,1],
  '|' => [5,2],
  '}' => [5,3],
  '~' => [5,4],
)

Now our text strings are going to be composed of sprites, so we'll have some of the normal sprite attributes in addition to one that tells us which spritesheet to use:

mutable struct CellphoneString <: Renderable
  function CellphoneString(str="", white=true; 
    scale=XYZ(1,1), color=colorant"white", kw...)
    instantiate!(new(); str=str, white=white, scale=scale, color=color, kw...)
  end
end

Now we can define its draw function, which determines which spritesheet to use and draws each character based on its position in the spritesheet:

function Starlight.draw(s::CellphoneString)
  img = (s.white) ? joinpath(asset_base,
                      "sprites", "charmap-cellphone_white.png") : \
                    joinpath(asset_base, 
                      "sprites", "charmap-cellphone_black.png")
  for (i,c) in enumerate(s.str)
    cell_ind = cpchars[c]
    TS_VkCmdDrawSprite(img, vulkan_colors(s.color)...,
    0, 0, 0, 0,
    7, 9, cell_ind[1], cell_ind[2],
    Int(floor(s.scale.x * 7)) * (i - 1) + s.abs_pos.x, s.abs_pos.y, 
              s.scale.x, s.scale.y)
  end
end

Note that for this and all other method "overloads", you must specify Starlight. or Julia will assume you want to define a method with that name inside the Main module and nothing will work.

Now, with all that complexity out of the way, we are free to write some excessively simple UI code (well, if you don't count the positioning calculations):

# p1 score
score1 = CellphoneString('0'; color=colorant"grey", 
  scale=XYZ(score_scale, score_scale), 
  pos=XYZ((window_width / 2) - (window_width / 4) - 
  (7 * score_scale / 2), score_y_offset))

# p2 score
score2 = CellphoneString('0'; color=colorant"grey", 
  scale=XYZ(score_scale, score_scale), 
  pos=XYZ((window_width / 2) + (window_width / 4) - 
  (7 * score_scale / 2), score_y_offset))

# welcome message
msg = CellphoneString("Press SPACE to start", false; 
  scale=XYZ(msg_scale, msg_scale), 
  pos=XYZ((window_width - 140 * msg_scale) / 2, 
  (window_height - 9 * msg_scale) / 2))

We're going to start handholding even less now. By this point you should be well up to speed on how Starlight works.

Our paddles are simple rectangles, so we can reuse the defaultDrawRect function. Notice the (recommended) pattern of managing physics entities inside awake! and shutdown! rather than in the constructor and finalizer. This is so that entities can be awoken and shutdown without Bullet continuing to simulate them, and also without other subsystems needing to know about physics.

Recall that Bullet uses half extents for its box shapes, so we calculate and then use those here.

Probably the most interesting "new" thing here is the collision handler, which deals with paddle/wall contacts. pg is something we'll see later, it refers to an instance of a PongGame entity that handles game state and input. Basically this collision handler tells the game manager whether to allow paddle movement in a particular direction.

mutable struct PongPaddle <: Starlight.Renderable 
  function PongPaddle(w, h; kw...)
    instantiate!(new(); w=w, h=h, color=colorant"white", kw...)
  end
end

Starlight.draw(p::PongPaddle) = defaultDrawRect(p)

function Starlight.awake!(p::PongPaddle)
  hw = paddle_width / 2
  hh = paddle_height / 2
  addTriggerBox!(p, hw, hh, hz, p.pos.x + hw, p.pos.y + hh, 0, pmx, pmy, 0)
end

function Starlight.shutdown!(p::PongPaddle)
  removePhysicsObject!(p)
end

function Starlight.handleMessage!(p::PongPaddle, col::TS_CollisionEvent)
  otherId = other(p, col)
  if otherId == wallt.id
    if p.id == p1.id
      pg.p1TouchingTopWall = col.colliding
    elseif p.id == p2.id
      pg.p2TouchingTopWall = col.colliding
    end
  elseif otherId == wallb.id
    if p.id == p1.id
      pg.p1TouchingBottomWall = col.colliding
    elseif p.id == p2.id
      pg.p2TouchingBottomWall = col.colliding
    end
  end
end

# p1
p1 = PongPaddle(paddle_width, paddle_height; 
  pos=XYZ(paddle_width, (window_height - paddle_height) / 2))

# p2
p2 = PongPaddle(paddle_width, paddle_height; 
  pos=XYZ(window_width - 2 * paddle_width, 
  (window_height - paddle_height) / 2))

Comparatively there is nothing special about the walls or goals, except that one gets drawn and the other doesn't:

mutable struct PongArenaWall <: Starlight.Renderable
  function PongArenaWall(w, h; kw...)
    instantiate!(new(); w=w, h=h, color=colorant"white", kw...)
  end
end

Starlight.draw(p::PongArenaWall) = defaultDrawRect(p)

function Starlight.awake!(p::PongArenaWall)
  hw = window_width / 2
  hh = wall_height / 2
  addTriggerBox!(p, hw, hh, hz, p.pos.x + hw, p.pos.y + hh, 0, wmx, wmy, 0)
end

function Starlight.shutdown!(p::PongArenaWall)
  removePhysicsObject!(p)
end

# top wall
wallt = PongArenaWall(window_width, wall_height; pos=XYZ(0, 0))

# bottom wall
wallb = PongArenaWall(window_width, wall_height; 
  pos=XYZ(0, window_height - wall_height))

mutable struct PongArenaGoal <: Starlight.Entity
  function PongArenaGoal(w, h; kw...)
    instantiate!(new(); w=w, h=h, kw...)
  end
end

function Starlight.awake!(p::PongArenaGoal)
  hw = goal_width / 2
  hh = window_height / 2
  addTriggerBox!(p, hw, hh, hz, p.pos.x + hw, p.pos.y + hh, 0, gmx, gmy, 0)
end

function Starlight.shutdown!(p::PongArenaGoal)
  removePhysicsObject!(p)
end

# left goal
goal1 = PongArenaGoal(window_height * 2, goal_width; 
  pos=XYZ(-goal_width, 0))

# right goal
goal2 = PongArenaGoal(window_height * 2, goal_width; 
  pos=XYZ(window_width, 0))

The ball is a simple rectangle, but its collision handler is where most of the game logic is run. Let's get the easy stuff out of the way:

mutable struct PongBall <: Starlight.Renderable
  function PongBall(w, h; kw...)
    instantiate!(new(); w=w, h=h, color=colorant"white", kw...)
  end 
end

Starlight.draw(p::PongBall) = defaultDrawRect(p)

function Starlight.awake!(p::PongBall)
  hw = ball_width / 2
  hh = ball_height / 2
  addTriggerBox!(p, hw, hh, hz, p.pos.x + hw, p.pos.y + hh, 0, bmx, bmy, 0)
end

function Starlight.shutdown!(p::PongBall)
  removePhysicsObject!(p)
end

...and now, the hard part. We confess that this Pong implementation is buggy, and a few of the bugs are in the following code. If you can fix them, please submit a pull request, we would greatly appreciate it.

Understand that this is mostly "Pong logic" however: there's not much here that's instructive about Starlight, except that it shows you how different components can be used to implement the logic of an actual game.

Combined with everything we've already covered, we have no compunction telling you to simply read the code if you're interested in it. :)

function hit_edge(p::PongBall, o::PongPaddle)
  if o.id == p1.id # left
    return p.abs_pos.x < (o.abs_pos.x + paddle_width - paddle_ball_x_tolerance)
  else # right
    return p.abs_pos.x > (o.abs_pos.x - ball_width + paddle_ball_x_tolerance)
  end
end

function hit_angle(p::PongBall, o::PongPaddle)
  paddle_top = o.abs_pos.y - ball_height / 2
  ball_center = p.abs_pos.y + ball_height / 2
  paddle_hit_area = paddle_height + ball_height
  return -(2 * ((ball_center - paddle_top) / paddle_hit_area) - 1)
end

function wait_and_start_new_round(arg)
  sleep(SLEEP_SEC(secs_between_rounds))
  pg.ball = newball()
end

getP1Score() = parse(Int, score1.str)
getP2Score() = parse(Int, score2.str)

function Starlight.handleMessage!(p::PongBall, col::TS_CollisionEvent)
  otherId = other(p, col)
  vel = TS_BtGetLinearVelocity(p.id)
  if col.colliding
    if otherId ∈ [wallt.id, wallb.id]
      TS_PlaySound(joinpath(asset_base, 
        "sounds", "ping_pong_8bit_plop.ogg"), 0, -1)
      TS_BtSetLinearVelocity(p.id, vel.x, -vel.y, vel.z)
    elseif otherId ∈ [goal1.id, goal2.id]
      TS_PlaySound(joinpath(asset_base, 
        "sounds", "ping_pong_8bit_peeeeeep.ogg"), 0, -1)
      destroy!(p)

      if otherId == goal2.id
        score1.str = string(getP1Score() + 1)
      elseif otherId == goal1.id
        score2.str = string(getP2Score() + 1)
      end

      if getP1Score() == score_to_win || getP2Score() == score_to_win
        msg.hidden = false
        score1.str = "0"
        score2.str = "0"
      else
        oneshot!(clk(), wait_and_start_new_round)
      end
    elseif otherId ∈ [p1.id, p2.id]
      TS_PlaySound(joinpath(asset_base, 
        "sounds", "ping_pong_8bit_beeep.ogg"), 0, -1)
      o = getEntityById(otherId)
      TS_BtSetLinearVelocity(p.id, (hit_edge(p, o) ? 1 : -1) * vel.x,
        ball_vel_y(hit_angle(p, o)), vel.z)
    end
  end
end

The PongGame does however have a few interesting things about it. It shows the use of input events and how they can help manage game state, as well as the recommended way to exit out of a Starlight app: defining an SDL_QuitEvent handler. Leaving this up to the user allows them to define custom behavior like "Do you really want to quit?" dialogs.

mutable struct PongGame <: Starlight.Entity
  function PongGame() 
    instantiate!(new(); ball=nothing, w=false, s=false, up=false, down=false, 
      p1TouchingTopWall=false, p2TouchingTopWall=false, 
      p1TouchingBottomWall=false, p2TouchingBottomWall=false)
  end
end

function Starlight.awake!(p::PongGame)
  listenFor(p, SDL_KeyboardEvent)
  listenFor(p, SDL_QuitEvent)
  TS_BtSetGravity(0, 0, 0)
end

function Starlight.shutdown!(p::PongGame)
  unlistenFrom(p, SDL_KeyboardEvent)
  unlistenFrom(p, SDL_QuitEvent)
end

function newball()
  p = PongBall(ball_width, ball_height; 
    pos=XYZ((window_width - ball_width) / 2, 
    (window_height - ball_height) / 2))
  TS_BtSetLinearVelocity(p.id, 
    ((rand(Bool)) ? 1 : -1) * ball_vel_x, ball_vel_y(2 * rand() - 1), 0)
  return p
end

function Starlight.handleMessage!(p::PongGame, key::SDL_KeyboardEvent)
  if key.keysym.scancode == SDL_SCANCODE_SPACE && !msg.hidden
    msg.hidden = true
    p.ball = newball()
  elseif key.keysym.scancode == SDL_SCANCODE_W
    p.w = key.state == SDL_PRESSED
  elseif key.keysym.scancode == SDL_SCANCODE_S
    p.s = key.state == SDL_PRESSED
  elseif key.keysym.scancode == SDL_SCANCODE_UP
    p.up = key.state == SDL_PRESSED
  elseif key.keysym.scancode == SDL_SCANCODE_DOWN
    p.down = key.state == SDL_PRESSED
  end
end


function Starlight.handleMessage!(p::PongGame, q::SDL_QuitEvent)
  shutdown!(p)
end

function Starlight.update!(p::PongGame, Δ::AbstractFloat)
  TS_BtSetLinearVelocity(p1.id, 0, 0, 0)
  TS_BtSetLinearVelocity(p2.id, 0, 0, 0)
  if p.w && !p.p1TouchingTopWall
    TS_BtSetLinearVelocity(p1.id, 0, -pv, 0)
  elseif p.s && !p.p1TouchingBottomWall
    TS_BtSetLinearVelocity(p1.id, 0, pv, 0)
  end
  if p.up && !p.p2TouchingTopWall
    TS_BtSetLinearVelocity(p2.id, 0, -pv, 0)
  elseif p.down && !p.p2TouchingBottomWall
    TS_BtSetLinearVelocity(p2.id, 0, pv, 0)
  end
end

pg = PongGame()

And with that, all that's left to do is run the App:

run!(a)

And...that's Pong with Starlight!