Implementing physics in a LÖVE game

Tutorial – LÖVE Physics

Article from Issue 235/2020
Author(s):

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
Figure 1: The scenery for your game.

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 variable dt is usually associated with love.update () so you can calculate game time. dt contains the time that has passed since the last time love.update () was called.
  • The love.draw () function is where you draw what will be seen on the screen after each iteration in love.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.

Figure 2: The outline of the ground.

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.

Figure 3: LÖVE's fill algorithm struggles with concave shapes.

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.

Figure 4: A polygon built up from triangles.

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

Express-Checkout as PDF
Price $2.95
(incl. VAT)

Buy Linux Magazine

SINGLE ISSUES
 
SUBSCRIPTIONS
 
TABLET & SMARTPHONE APPS
Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content

  • Bang! Ding! Crash!

    To create an action-packed game with LÖVE, these are a few last things you should learn how to do – overlay fancy images to "physical" objects, detect collisions, and get input from the keyboard or mouse.

comments powered by Disqus
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.

Learn More

News