Implementing physics in a LÖVE game
Tutorial – LÖVE Physics
Video game animation is not simply a matter of making your characters move – you also have to consider the physics of the world in which they move.
In issue 234 of Linux Magazine [1], I introduced LÖVE [2], the Lua-based framework used for creating 2D games, by drawing a character, Cubey McCubeFace, who could walk across the screen. Now I'm going to explore another aspect of LÖVE by going back into an animated world and causing an object to fall out of the sky.
As long as your game characters are moving from side to side, things are more or less easy. The moment you need them to jump or fall, things get more complicated – that is, if you have to program a physics engine yourself. Luckily, LÖVE provides a way to simulate 2D rigid bodies in a realistic manner through its physics module. In this tutorial, I'll explore how that works [3].
Landscaping
First of all you need a playing field in which things can move around and collide with each other. I'll set up a "landscape" like the one you can see in Figure 1 and by drawing the outline of the terrain first (see Listing 1).
Listing 1
main.lua (Original)
01 math.randomseed(os.time()) 02 w_width = 900 03 w_height = 600 04 05 function love.load () 06 love.window.setMode (w_width, w_height, {resizable = false}) 07 love.graphics.setBackgroundColor (0.5, 0.8, 1, 1) 08 09 level01 = w_height - (math.random (10, (w_height / 3))) 10 level02 = w_height - (math.random (10, (w_height / 3))) 11 12 if level01 < level02 13 then 14 mountain = level01 - (math.random (10, (w_height / 3))) 15 else 16 mountain = level02 - (math.random (10, (w_height / 3))) 17 end 18 19 ground = { 0, w_height, 20 0, level01, 21 w_width / 3, level01, 22 w_width / 2, mountain, 23 w_width * (2 / 3), level02, 24 w_width, level02, 25 w_width, w_height 26 } 27 end 28 29 function love.update () 30 31 end 32 33 function love.draw () 34 love.graphics.setColor (0, 0, 0, 1) 35 love.graphics.polygon ("line", ground) 36 end
As you will remember from the article in the last issue of Linux Magazine [1], a LÖVE game is usually divided into three parts: the load, the update, and the draw (see the box "Anatomy of LÖVE" for more on this). You can use LÖVE's polygon ()
function in the draw section to create the ground in your game (line 35). The polygon ()
function takes a mode
argument, 'line'
for an outline or 'fill'
for a filled polygon and then a bunch of coordinates for the polygon's vertices. If you then define two variables, say w_width
and w_height
(lines 2 and 3), for the size of the playing field, you can then use them to correctly place the ground. As the (0, 0)
position in a LÖVE screen is in the upper left-hand corner, the first vertex for the ground, in the lower, left-hand corner, will be at (0, w_height)
. The second vertex will be somewhere above that, so at (0, w_height - some random number)
, and the third will be one-third across at the same height at (w_width / 3 , w_height - some random number)
; and so on. Coded into LÖVE, that would look like lines 9 to 26 in Listing 1.
Anatomy of LÖVE
A LÖVE game is usually split into three distinct parts, each defined by its own function:
- The
love.load ()
function is where you set things up. You load images, set the background, calculate the frames in each animation, set the initial values of variables, create objects, and so on. - The
love.update ()
function is the main loop of the game. Here is where things change as the game progresses. You calculate the new coordinates for sprites; read in keystrokes, mouse movements, or other player-generated input; modify the playing field; and so on. The special variabledt
is usually associated withlove.update ()
so you can calculate game time.dt
contains the time that has passed since the last timelove.update ()
was called. - The
love.draw ()
function is where you draw what will be seen on the screen after each iteration inlove.update ()
.
Lua's math.randomseed ()
module (line 1) makes sure that the math.random ()
random functions (lines 9, 10, 14, and 16) will return a different set of numbers each time the game is run. Lines 12 to 17 make sure that the central mountain sticks out above the two sides of the playing field (i.e., that it is actually a mountain and not a valley), and lines 19 to 26 put all the vertices into a table that you can then use as an argument with polygon ()
on line 35. When you run this program, it will show what you can see in Figure 2.
You may think filling in the "ground" shape would be as simple as adding
love.graphics.polygon ("fill", ground)
to the love.draw ()
function, but that is not the case.
You see, LÖVE uses a very fast filling algorithm. It needs to if it has to redraw and fill several polygons many times a second. But the trade-off is that it is not very good at filling in concave shapes ( i.e., shapes with dents and holes in them). When you try to 'fill'
in the ground polygon, you get what you can see in Figure 3.
As you can see, the left side of the playing field is wrong: The fill algorithm has filled in a triangle that goes from the lower left-hand corner of the window to the top of the hill, cutting over the flat area in the left side of the field.
The way you solve this is with triangulation. LÖVE incorporates its own math
module that includes a function, triangulate ()
that breaks complex polygons into triangles.
Add the line
groundT = love.math.triangulate (ground)
after you define the ground
table, and groundT
will fill up with the vertices from a bunch of triangles that, when put together, make up your polygon ground.
As all triangles are convex, you can then loop over each of them and fill each to draw the ground, as shown in lines 9 to 11 in Listing 2.
Listing 2
The Ground, draw ()
01 . 02 . 03 . 04 function love.draw () 05 love.graphics.setColor (0, 0, 0, 1) 06 love.graphics.polygon ('line', ground) 07 08 love.graphics.setColor (0.5, 0.3, 0, 1) 09 for i=1, #groundT do 10 love.graphics.polygon ('fill', groundT [i]) 11 end 12 end 13 . 14 . 15 .
Figure 4 shows what the triangles look like when made visible.
Physical Ground
So far, the ground is just a graphical element and nothing will interact with it. In fact, no graphical element ever physically interacts with any other graphical element in LÖVE. When graphical elements seem to fall, collide, and bounce, what they are really doing is taking the data for their position and rotation from invisible bodies defined by the physics module. These bodies are the ones doing the falling, colliding, and bouncing.
Before you start to make bodies tumble, you need some rules for your world. That is the purpose of the love.physics.setMeter ()
and love.physics.newWorld ()
functions.
The love.physics.setMeter ()
function determines how many pixels make a meter in your world. It is best to run this function before doing anything else with physics, since if you change it halfway through, things will get weird, as objects drawn in one scale before the change will remain in that scale, while objects drawn in the new scale will use the new scale. The default value for pixels-to-meters is 30 pixels for one meter, but you can change that to, say, 10 pixels to a meter with
love.physics.setMeter (10)
The love.physics.newWorld ()
takes three parameters: the strength of the horizontal component of gravity (yes, you can have things falling sideways), the strength of the vertical component of gravity, and whether objects in this world can sleep.
In this example, gravity is going to behave as usual and drag things down towards the bottom of the world. It will do that at its regular rate of 9.81m/s2, too. To do that, you can use love.physics.newWorld ()
as shown on line 2 of Listing 3.
Listing 3
pworld.lua
01 love.physics.setMeter (30) 02 world = love.physics.newWorld (0, 9.81 * 30, true) 03 04 Earth = {} 05 06 function Earth:init (terrain) 07 self.ground = {} 08 for i=1, #terrain.groundT do 09 self.ground[i] = {} 10 self.ground[i].body = love.physics.newBody (world, 0, 0, 'static') 11 self.ground[i].shape = love.physics.newPolygonShape (terrain.groundT [i]) 12 self.ground[i].fixture = love.physics.newFixture (self.ground [i].body, self.ground [i].shape) 13 end 14 end
By multiplying gravity's rate of acceleration by the setMeter
value, you will achieve a natural-looking fall for your objects.
The third argument, is the sleep
argument. If it is set to true
, it means that objects that are not moving or being interacted with are allowed to sleep. The interpreter will not waste cycles on them until something collides with them and they start moving again.
In Listing 3, I have also separated the world and ground configuration from the rest of the code, just to keep stuff tidy.
Listing 4 shows main.lua
, from which you call all the rest of the files and their components.
Listing 4
main.lua (Final)
01 require "scenery" 02 require "pworld" 03 require "pobject" 04 05 w_width = 900 06 w_height = 600 07 08 function love.load () 09 love.window.setMode (w_width, w_height, {resizable = false}) 10 love.graphics.setBackgroundColor (0.5, 0.8, 1, 1) 11 12 terrainG = Scenery 13 terrainG:init () 14 15 terrainP = Earth 16 terrainP:init (terrainG) 17 18 object = Box 19 object:init (460, 50, 50, 0.5, 0.2) 20 end 21 22 function love.update (dt) 23 world:update(dt) 24 end 25 26 function love.draw () 27 terrainG:draw () 28 object:draw() 29 end
As you can see on line 1 of Listing 4, I have also separated the graphical component of the terrain into its own file (Listing 5) and now access its attributes and modules using Lua's object-like calls.
Listing 5
scenery.lua
01 math.randomseed(os.time()) 02 03 Scenery = {} 04 05 function Scenery:init () 06 level01 = w_height - 60 -- (math.random (10, (w_height / 3))) 07 level02 = w_height - 120 -- (math.random (10, (w_height / 3))) 08 09 if level01 < level02 10 then 11 mountain = level01 - 80 --(math.random (10, (w_height / 3))) 12 else 13 mountain = level02 - 80 --(math.random (10, (w_height / 3))) 14 end 15 16 self.ground = { 0, w_height, 17 0, level01, 18 w_width / 3, level01, 19 w_width / 2, mountain, 20 w_width * (2 / 3), level02, 21 w_width, level02, 22 w_width, w_height 23 } 24 25 self.groundT = love.math.triangulate (self.ground) 26 end 27 28 function Scenery:draw () 29 love.graphics.setColor (0, 0, 0, 1) 30 love.graphics.polygon ('line', self.ground) 31 32 love.graphics.setColor (0.5, 0.3, 0, 1) 33 for i=1, #self.groundT do 34 love.graphics.polygon ('fill', self.groundT[i]) 35 end 36 end
On line 12 of main.lua
(Listing 4), you create a Scenery
object called terrainG
, and you call its initiation function on line 13. This does all the calculating of random levels, defining the polygon's shape, and triangulating (Listing 5, lines 5 to 26) I talked about in my first, standalone example.
Back in Listing 4, on line 15 you create another object, terrainP
("P" for "Physical"), which will be the physical representation of the terrain. On line 16, you pass the graphical terrain object terrainG
to the terrainP
's init ()
function.
To see what init()
does, turn to Listing 3 (pworld.lua
). As with the fill algorithm I mentioned above, LÖVE's physics engine has problems with concave bodies, so what you take from terrain
is its groundT
attribute, as this contains all the triangular shapes you calculated on line 25 of Listing 5.
As you can extract the number of items in a Lua table using the #
operator, it is simply a matter of iterating the triangles (lines 8 to 13 in Listing 3) and creating a corresponding physical body for each.
To make a LÖVE physics body (and the terrain is a body), you need to register it in world
(Listing 3, line 10). The second two arguments are its relative initial placement. As you are "drawing" the physical triangles that make up the physical ground relative to the upper left-hand corner of the playing field, use 0, 0
. The 'static'
argument means that the objects will not move and are as if stuck to the world.
The next step is to define the shape of the object. You do that with the newPolygonShape ()
function (Listing 3, line 11). This function takes a list of vertices that, in this case, you can get from the graphical ground element you define on line 25 of Listing 5.
A fixture (Listing 3, line 12) is what actually attaches the shape to the body and can also be used to define more qualities of a body, such as its density, bounciness, or friction. You will be playing around with those later, when I talk about moving bodies. For the ground, you only need the body's shape.
And that's it: Once the loop runs though all the triangles in groundT
, you will have an equivalent set of invisible, but physical triangles overlaying the ones you can see on the screen.
Now let's make a body that will interact with the ground.
Drop Box
Listing 6 defines a square box that falls onto the mountain and then bounces and slides until it stops (or slips off the edge of the world).
Listing 6
pobject.lua
01 Box = {} 02 03 function Box:init (posx, posy, size, bounciness, friction) 04 self.body = love.physics.newBody (world, posx, posy, 'dynamic') 05 self.shape = love.physics.newRectangleShape (size, size) 06 self.fixture = love.physics.newFixture (self.body, self.shape, 1) 07 self.fixture:setRestitution (bounciness) 08 self.fixture:setFriction (friction) 09 end 10 11 function Box:draw () 12 love.graphics.setColor (0.76, 0.18, 0.05) 13 love.graphics.polygon ('fill', self.body:getWorldPoints (self.shape:getPoints ())) 14 15 love.graphics.print ('Friction: ' .. string.format ("%.2f", self.fixture:getFriction ()) , 10, 10, 0, 2) 16 love.graphics.print ('Bouncy: ' .. string.format ("%.2f", self.fixture:getRestitution ()) , 10, 40, 0, 2) 17 end
The init ()
function is very similar to that of the physical ground shown in Listing 3: You register the body into the world (line 4, Listing 6), except that, in this case, the body is 'dynamic'
because it moves around; then you define its shape (a square) on line 5; and attach the shape to the body with the newFixture ()
function (line 6).
What is new is that you define two new qualities of the body: setRestitution ()
establishes how bouncy an object is. It takes a number between
(no bounce) and 1
(very bouncy indeed, so much so that if you drop an object with a restitution of 1
onto a flat surface, it will bounce up to its original height again and never stop bouncing).
setFriction ()
is equally straightforward: 1
is total friction, no slipperiness at all (an object will stop in its tracks immediately); and
is no friction, so total slipperiness and the object will slip and slide into infinity.
The Box
table/class comes with another module, draw ()
(lines 11 to 17), which draws the object to the playing field. As drawn, rectangles have perfectly horizontal and vertical sides, you have to draw a polygon (line 13) if you want your object to spin realistically when hitting the ground.
To find out the location of the vertices of the polygon as it spins, you need the physical object's getPoints ()
function. This function returns the position of a body's vertices relative to its parent (a body can be part of a multi-body object). To transform those coordinates into world coordinates (the coordinates that will establish the body's vertices in the playing field), you need to use the getWorldPoints ()
function. That is what happens on line 13.
Finally, on lines 15 and 16, I print out the values of the body's friction and bounciness for informational purposes. The string.format ()
is part of Lua's standard arsenal of modules and formats the values so they show only up to two decimal places; Lua uses ..
to concatenate strings. After the string you want to print, you tell LÖVE the coordinates of where you want to print it, the rotation (in radians), and the size multiplier.
Buy this article as PDF
(incl. VAT)
Buy Linux Magazine
Subscribe to our Linux Newsletters
Find Linux and Open Source Jobs
Subscribe to our ADMIN Newsletters
Support Our Work
Linux Magazine content is made possible with support from readers like you. Please consider contributing when you’ve found an article to be beneficial.
News
-
Linux Sees Massive Performance Increase from a Single Line of Code
With one line of code, Intel was able to increase the performance of the Linux kernel by 4,000 percent.
-
Fedora KDE Approved as an Official Spin
If you prefer the Plasma desktop environment and the Fedora distribution, you're in luck because there's now an official spin that is listed on the same level as the Fedora Workstation edition.
-
New Steam Client Ups the Ante for Linux
The latest release from Steam has some pretty cool tricks up its sleeve.
-
Gnome OS Transitioning Toward a General-Purpose Distro
If you're looking for the perfectly vanilla take on the Gnome desktop, Gnome OS might be for you.
-
Fedora 41 Released with New Features
If you're a Fedora fan or just looking for a Linux distribution to help you migrate from Windows, Fedora 41 might be just the ticket.
-
AlmaLinux OS Kitten 10 Gives Power Users a Sneak Preview
If you're looking to kick the tires of AlmaLinux's upstream version, the developers have a purrfect solution.
-
Gnome 47.1 Released with a Few Fixes
The latest release of the Gnome desktop is all about fixing a few nagging issues and not about bringing new features into the mix.
-
System76 Unveils an Ampere-Powered Thelio Desktop
If you're looking for a new desktop system for developing autonomous driving and software-defined vehicle solutions. System76 has you covered.
-
VirtualBox 7.1.4 Includes Initial Support for Linux kernel 6.12
The latest version of VirtualBox has arrived and it not only adds initial support for kernel 6.12 but another feature that will make using the virtual machine tool much easier.
-
New Slimbook EVO with Raw AMD Ryzen Power
If you're looking for serious power in a 14" ultrabook that is powered by Linux, Slimbook has just the thing for you.