How The Collision Demo Works
---TECH TALK---
The collision demo is interesting in that it uses entirely **map coordinates** for its collisions. Let's take a peek!
Here you can see a short snippet of how the demo looks. The player character bunny can run around and be stopped by walls or other solid objects. They can pick up coins, and things can bounce off of each other. Each of these items, the player, the little blue fellow, the red ball, the bubble, and the coins, are all actors in our code.
Here's how this demo defines its actors:
(original code by Zep, additional comments by me)
actor = {} -- all actors
-- make an actor
-- and add to global collection
-- x,y means center of the actor
-- in map tiles
function make_actor(k, x, y)
a={
k = k, -- sprite number on the sprite sheet
x = x, -- Map X position
y = y, -- Map Y Position
dx = 0, -- X Speed
dy = 0, -- Y Speed
frame = 0, -- current frame offset
t = 0, -- timer for how long this actor has existed (unused)
friction = 0.15,
bounce = 0.3,
frames = 2, -- total frames of animation
-- half-width and half-height
-- slightly less than 0.5 so
-- that will fit through 1-wide
-- holes.
w = 0.4,
h = 0.4
}
add(actor,a)
return a
end
The _update() function (which runs every frame) is quite simple:
function _update()
control_player(pl)
foreach(actor, move_actor)
end
control_player checks for button inputs and increases the player's dx or dy depending on which arrow key was pressed.
The real meat of how this engine works come in the form of move_actor and subsequently the functions it calls.
function move_actor(a)
-- only move actor along x
-- if the resulting position
-- will not overlap with a wall
if not solid_a(a, a.dx, 0) then
a.x += a.dx
else
a.dx *= -a.bounce
end
-- ditto for y
if not solid_a(a, 0, a.dy) then
a.y += a.dy
else
a.dy *= -a.bounce
end
-- apply friction
-- (comment for no inertia)
a.dx *= (1-a.friction)
a.dy *= (1-a.friction)
-- advance one frame every
-- time actor moves 1/4 of
-- a tile
a.frame += abs(a.dx) * 4
a.frame += abs(a.dy) * 4
a.frame %= a.frames
a.t += 1 -- unused
end
Essentially, in X and Y, only move in that direction if doing so would not cause you to overlap with something solid. If it would, instead bounce off of that thing. This works pretty well and can be adapted into a great many kinds of games where things move around in real time. We'll talk more about the limitations of this system later on. Let's dig into "solid_a", to really understand when an actor is allowed to move somewhere or not.
-- checks both walls and actors
function solid_a(a, dx, dy)
if solid_area(a.x+dx,a.y+dy,
a.w,a.h) then
return true end
return solid_actor(a, dx, dy)
end
Simply put, if we check an actor a who is moving with velocities dx and dy against two other functions, solid_area and solid_actor. If either of these return true, then solid_a will return true.
solid_area does a series of checks on the four corners of this object's collision rectangle, checking from (x - width) to (x + width) and (y - height) to (y + height). Our X/Y coordinate in this system then represents the center of our actor and their hitbox is similarly centered.
-- solid_area
-- check if a rectangle overlaps
-- with any walls
--(this version only works for
--actors less than one tile big)
function solid_area(x,y,w,h)
return
solid(x-w,y-h) or
solid(x+w,y-h) or
solid(x-w,y+h) or
solid(x+w,y+h)
end
This series of checks is essentially the simple rectangle overlap discussed before, but calculated outwards from the center to find the points of our rectangles.
solid() utilizes a pair of very common PICO-8 funtions, mget() and fget(). mget() checks the map tile at map coordiante x,y and returns the sprite number it finds. fget() tells you what flags have been set for that sprite.
-- for any given point on the
-- map, true if there is wall
-- there.
function solid(x, y)
-- grab the cel value
val=mget(x, y)
-- check if flag 1 is set (the
-- orange toggle button in the
-- sprite editor)
return fget(val, 1)
end
In an earlier Ball Ninja example, we're checking to see if a map tile has flag one (the orange flag), which we choose to represent a solid wall.
solid_actor() on the other hand is more complicated. After all, any of the other actors in our scene could be moving at any velocity! How do you know if a given actor is about to bump into any other? Simply, you check every actor against every other actor it could bump into every single frame. This function is comparatively long, so I've added some comments to it to explain as we go.
-- true if a will hit another
-- actor after moving dx,dy
-- also handle bounce response
-- (cheat version: both actors
-- end up with the velocity of
-- the fastest moving actor)
function solid_actor(a, dx, dy)
for a2 in all(actor) do
-- Make sure we're not comparing collision against ourselves. After all, both the actor
-- we're checking and the actor we're comparing against are part of the same list, so without
-- this check we'd always register a collision against ourselves.
if a2 != a then
-- Store the differences between where our actor a will be and the compared actor a2 currently is
local x=(a.x+dx) - a2.x
local y=(a.y+dy) - a2.y
-- We use the absolute value here to avoid doing two checks. We only care that the difference
-- between the two actors is less than their widths. Consider this example:
if ((abs(x) < (a.w+a2.w)) and
(abs(y) < (a.h+a2.h)))
abs(35 - 63) = 28
width1(15) + width 2(18) = 33
33 > 28, so they are overlapping on the X axis by five pixels. If the same is also true for Y, the two rectangles are overlapping. Continuing,
if ((abs(x) < (a.w+a2.w)) and
(abs(y) < (a.h+a2.h)))
then
-- If we reach here, the two will collide.
-- moving together?
-- this allows actors to
-- overlap initially
-- without sticking together
-- process each axis separately
-- along x
if (dx != 0 and abs(x) <
abs(a.x-a2.x))
then
v=abs(a.dx)>abs(a2.dx) and
a.dx or a2.dx
a.dx,a2.dx = v,v
-- collide event is a function used to determine if one of these
-- actors should remove the other when they collide
-- (e.g. A player picks up a coin)
-- or if they should just bounce off each other
local ca=
collide_event(a,a2) or
collide_event(a2,a)
return not ca
end
-- along y
if (dy != 0 and abs(y) <
abs(a.y-a2.y)) then
v=abs(a.dy)>abs(a2.dy) and
a.dy or a2.dy
a.dy,a2.dy = v,v
local ca=
collide_event(a,a2) or
collide_event(a2,a)
return not ca
end
end
end
end
-- If we reach here, we've checked against every other actor and there's no collisions
return false
end
So simply put, if we're about to move into a wall we bounce, and if we're about to move into an actor then figure out what that interaction should be based on who those actors are.
One small note about this collision system before we go: This uses X and Y as the center of our actors. However, the draw function spr() uses X and Y as the upper left corner of a sprite. So for this example, where all of our actors are 8x8, we can offset by 4 in each direction after we convert their coordinates from map to screen to draw the actors in the correct position.
function draw_actor(a)
local sx = (a.x * 8) - 4
local sy = (a.y * 8) - 4
spr(a.k + a.frame, sx, sy)
end
---END TECH TALK---
Limitations of this system
Overall this system works pretty well and can be adapted for a great number of different games. However, in its current form, there are some things it cannot do. Consider the simple case where you don't want your actors to bounce when they bump into things. If you remove the bounce (which I encourage you to try), fast objects will be stopped from moving far before they actually hit walls, since instead of bouncing they are simply stopped from moving. If you remove bounce and add gravity, this problem becomes even more apparent, since a fast falling object ends up hovering above the ground.
One solution to this is to break up your collision checks into smaller chunks, slowly inching your object towards their directions until they hit the collision. But if you take this approach in a map coordinate system, what unit do you use to inch forward your objects? Using a full tile would get you almost nowhere. You could use a tenth of a tile, which would probably work fine but may still occasionally result in some weird behavior. If you want something like a Ninja Gaiden (or a Celeste) where a character can grab onto walls with pixel perfect positioning, you need to consider ways of extending or changing this system. Remember: collision principles are universal, but collision implementations are rarely one size fits all! What happens when entities collide and how precise you are in your calculations are up to what kind of game you're making and how your implementation solves the collision problems of that game!
Now, let's look at how Celeste, a platformer with pixel perfect collision, deals with this in our next post.
(Small bonus note: You can now find this page by accessing the URL ball.ninja)