Learn Lobster: Let's create a 2D shooter!

Let's walk through all the steps required to make a simple game in Lobster by making a shooter in the same vein as "Geometry Wars": enemies come at you from all directions, and you have to keep shooting them until you invariably... die. Sound like fun?

You can follow along just looking at the code and screenshots below, or by loading up samples/shooter_tutorial/tut*.lobster and trying them out yourself.

Decent experience in at least one other programming language required. Minor familiarity with Lobster is required (this tutorial won't explain all the basics, and focuses mostly on the graphics aspect). No need to know how games work, though minor math knowledge may help.

1: Graphics Setup

Let's start by creating a blank canvas for us to draw on. Something like this:

Impressively... empty.

import vec
import color
import gl

fatal(gl.window("Shooter Tutorial", 640, 480))

while gl.frame() and gl.button("escape") != 1:
    gl.clear(color_black)

gl.window is our graphics initialization routine, and is the first thing we need to do before we can access any other graphics functionality. We specify a title and a size, though note that these are merely suggestions: on desktop platforms the user may resize/maximize our window, and on mobile platforms they just determine landscape vs portrait orientation, whereas the resolution is determined by the device itself.

gl.window returns an error string if something goes wrong (and things can go wrong, behind the scenes this call boots up an OpenGL engine including compiling shaders and what not), and fatal is a utility function defined in std.lobster that we're including that will quit the program and show the error string if available.

Once we're up and running, this while loop is our frame loop. Games work by rendering the current state of the game repeatedly to the screen, preferably fast (30-60 times per second), giving the impression of smooth animation. gl.frame is the core of all of this: it makes sure we advance to rendering the next frame, and checks for input etc. If the user clicks the close button or otherwise terminates the app, gl.frame will return false, which is our signal to quit the game. Additionally, we want the user to be able to quit if they press the escape key, so we check for that too.

Then inside the frame loop, we clear the screen (remember that we draw repeatedly), and then we're ready to draw. color_black is a constant from color.lobster, representing a 4 component RGBA.

2: Drawing and World Space

So now we can start drawing, right? Well, we also have to determine where and how we want to draw:

Look at that! This is starting to look impressive! Check out those polygons!

import std
import vec
import color
import gl

fatal(gl.window("Shooter Tutorial", 640, 480))

let worldsize = 20.0

while gl.frame() and gl.button("escape") != 1:
    gl.clear(color_black)
    gl.color(color_white)
    gl.translate(float(gl.window_size()) / 2.0)
    gl.scale(gl.window_size().y / worldsize)
    gl.circle(1.0, 6)

Before we can actually draw, we have to talk about coordinate systems. By default, coordinates in Lobster correspond directly to pixels, with (0,0) in the upper left corner (a left-handed coordinate system), and the total number of pixels depending on the device (or the user), which during any frame is given by gl.window_size. While working directly with pixel sizes may be useful for some applications, generally, we want games to be scalable, meaning they should roughly look the same irrespective of what device they run on.

There is no universal way to do this for games however, because besides different resolutions, you have the more problematic issue of different aspect ratios, varying from 4:3 on the iPad (relatively square) to 3:2, 16:10 and 16:9 (longitudinal) on most desktops and other mobile devices. Your game should ideally look good on that whole range. If you only aim for one ratio, you'll get borders or things cut off on the other ratios. What works best depends on your game content.

Here we'll take a simple approach: make the middle of the screen our coordinate system origin, and rendering outward enough from there that we have all ratios covered.

gl_translate changes our coordinate system, and is used throughout graphics rendering in Lobster to render things at different portions of the screen. Here we move from upper-left to the middle of the screen by translating by half the gl.window_size.

Then we have to determine how much of the game world we want to show. We don't want that related to pixels either, as we don't want people with smaller screens see less of the game. The computer doesn't care about real world sizes like feet or meters, so we can make up whatever number we want. It is useful however to pick a number, since that means if we later want to show more or less of the gameworld, we just have to change one number. Here we say we want the screen to be able to fit at least 20 units, and we make this relative to the y resolution of the screen (we're assuming that'll be the smaller one of the two).

Now we can finally draw! Let's put down a temporary graphic for the player. gl.circle draws circles around the current origin, with the first argument being the radius (see how we're using the world scale for this?), and the second argument is the number of sides. Setting this to something low like 6 actually gets us a hexagon rather than a circle.

Pfew, that's a lot of talk just for a circle, but we're getting somewhere. Next up, let's move our player around. Because games are supposed to be... interactive

3: Input, Movement, and Collision

It moved! I swear!

import std
import vec
import color
import gl

fatal(gl.window("Shooter Tutorial", 640, 480))

let worldsize = 20.0
var playerpos = float2_0
let playerspeed = 10

while gl.frame() and gl.button("escape") != 1:
    gl.clear(color_black)
    gl.color(color_white)

    gl.translate(float(gl.window_size()) / 2.0)
    let scale = gl.window_size().y / worldsize
    gl.scale(scale)

    let dir = float2 { (gl.button("d") >= 1) - (gl.button("a") >= 1),
                     (gl.button("s") >= 1) - (gl.button("w") >= 1) }
    let newpos = playerpos + normalize(dir) * gl.delta_time() * playerspeed
    if not any(abs(newpos) > float(gl.window_size()) / scale / 2.0):
        playerpos = newpos

    gl.translate playerpos:
        gl.circle(1.0, 6)

To make our player move, we added 2 new variables: playerpos and playerspeed. We initialize the former with a vector constant from vec.lobster: float2_0 means all zeroes.

We first figure out which direction the player wants to move by checking the current state of the WASD keys: by combining the boolean values (0 / 1) for each direction, both DA and SW give us a -1 / 0 / 1 value which conveniently corresponds to a directional vector (dir).

Now, we can't just add this vector to the player and be done with it, we have to take into account:

Now we have computed a new position, we have to make sure it has an effect. We do this by translating our player position from the middle to where we want to now draw our hexagon. Note something special about this gl.translate: it has a block of code following it (notice the :) much like if or for. gl.translate here actually works like a control structure: it first translates, then executes the body, then resets the translation. This is because what we want to render afterwards (enemies perhaps) should not be rendered relative to the player, but relative to what we had before the player (the world origin). If that sounds confusing, it may become clearer later with more code. Stacking all these transformations is one of the more confusing things about games programming, but it is also rather powerful once you get the hang of it.

4: Orientation and Shooting

What happens as I move the mouse cursor in a clockwise arc around the player from 12 to 3.

import std
import vec
import color
import gl

fatal(gl.window("Shooter Tutorial", 640, 480))

let worldsize = 20.0

var playerpos = float2_0
let playerspeed = 10

class bullet:
    pos:float2
    dir:float2

let firerate = 0.1
let bulletspeed = 15
var bullets = []
var lastbullet = gl.time()

while gl.frame() and gl.button("escape") != 1:
    gl.clear(color_black)
    gl.color(color_white)

    gl.translate(float(gl.window_size()) / 2.0)
    let scale = gl.window_size().y / worldsize
    gl.scale(scale)

    let dir = float2 { (gl.button("d") >= 1) - (gl.button("a") >= 1),
                     (gl.button("s") >= 1) - (gl.button("w") >= 1) }
    let newpos = playerpos + normalize(dir) * gl.delta_time() * playerspeed
    if not any(abs(newpos) > float(gl.window_size()) / scale / 2.0):
        playerpos = newpos

    let tomouse = normalize(gl.local_mouse_pos(0) - playerpos)

    if lastbullet < gl.time():
        bullets.push(bullet { playerpos, tomouse })
        lastbullet += firerate

    for(bullets) b:
        b.pos += b.dir * gl.delta_time() * bulletspeed
        gl.translate b.pos:
            gl.color color_yellow:
                gl.circle(0.2, 20)

    bullets = filter(bullets) b:
        magnitude(b.pos) < worldsize * 2.0

    gl.translate gl.local_mouse_pos(0):
        gl.line_mode 1:
            gl.color color_grey:
                gl.circle(0.5, 20)

    gl.translate playerpos:
        gl.rotate_z tomouse:
            gl.polygon([ float2 { -0.5, 0.5 }, float2_x, float2 { -0.5, -0.5 } ])

To be able to shoot, first we have to worry about giving our player an orientation. We compute that in the vector tomouse which we get by subtracting the player position from the mouse position (what we want to shoot towards). Something funny is going on here though, as the name gl.local_mouse_pos may indicate: normally mouse positions are in pixels, but those we can't compare against the player position, which is in world coordinates! gl.local_mouse_pos however gives us the mouse position relative to the current transform, which is world coordinates (as specified by the gl.translate and gl.scale above). We then normalize this vector to make it easier to use, as we don't care about the original length of this vector.

To make the players orientation visual, we first render the player differently: rather than a simple circle, we render him as a pointy triangle, to make it clear what direction he's looking at. That's the gl.polygon at the end with 3 explicit coordinates (relative to the playerpos, which has now become our coordinate system origin thanks to gl.translate). Additionally, we insert a gl.rotate_z command to make the pointy part of the triangle always look towards the mouse cursor. The z is because if we rotate around the z axis, we end up rotating the xy plane, which is what we look at in 2D. The argument to gl.rotate_z can either be a vector (as used here) or an angle in degrees. Vectors are generally awesomer.

Additionally, to cater for situations where the mouse cursor isn't visible (which it typically isn't in games) we draw a circle (for now) at the mouse location to give the player feedback where he's aiming at. This starts at the gl.translate before where we draw the player (before, because if the two overlap, the last one will be drawn on top). We translate to the mouse location in world space, change the linemode to 1 (which draws just the outline instead of a filled circle) and change the color. As you can see, all these commands are ones that undo themselves automatically after the circle is drawn, which is very convenient.

Now let's start firing bullets. To keep things simple for now, we fire them automatically, but firing them using a button would be very easy to add (try it!). We add some constants up top, a firerate (second to fire the next bullet) and their speed of travel in the world. To keep track of what's going on, we just need a list of bullets, and track when the last bullet was fired.

We define what a bullet is by defining a new class type, saying that it just has a current position and direction.

Now look at the if inside the code: we check if time has progressed beyond the point the last bullet was supposed to be fired, and if so, we add a new bullet to the list of bullets, making it start at the current player position, travelling in the direction of the mouse. We also increase the lastbullet variable to cause the next one to be fired in 0.1 seconds.

A side note on how we're implementing bullets here: there's a couple of things not ideal about this, which we do all to keep the example simple and to really build a game from the ground up. First, the way the checking for time is implemented here is a bit fragile, as big time fluctuations may cause bullets to be shot at irregular intervals.

Now that we have a list of bullets being generated, we have to update them and draw them. The first line in our for loop updates them, in a manner that should look familiar by now: we move the position along the direction, scaled by the amount of time that has passed this frame and their general speed in world units per second.

Now they're moving, we can draw them at their current position. We make them yellow, and 0.2 units in radius.

One last thing to before we're done: our list of bullets will grow indefinitely, and eventually make the game run to a crawl, even though most have moved off screen. We'll cull bullets that have moved far enough away from the origin that they're not visible anymore. We'll do this functional style: filter will give us a new list of bullets for which the magnitude of the position (which is the same as a vector from the origin, which is the middle of the screen) is within twice the world size. We could cull them more precisely than this, but for now, this will have the desired effect of keeping the list small.

Wow, there's a lot to understand to make even a simple game. But the good news is that in many games you will see the patterns of what we're doing here again and again, so it is rather useful to get familiar with. It even translates to 3D.

But for now... target practice!

5: Enemies

Since the code is getting long, let's continue, talking purely in terms of modifications to the existing code:

def renderpointytriangle(pos, dir):
    gl.translate pos:
        gl.rotate_z dir:
            gl.polygon([ float2 { -0.5, 0.5 }, float2_x, float2 { -0.5, -0.5 } ])

First, let's take the code for rendering the player and put it in its own function, since we'll be needing it for enemies too. Call instead of the original code with renderpointytriangle(playerpos, tomouse)

class enemy:
    pos:float2
    hp:int

var enemyrate = 1.0
let enemyspeed = 3
let enemymaxhp = 5
var enemies = []
var lastenemy = gl.time()

We can set up enemies analogous to bullets. Of course, we could share some of this functionality between them, but let's not complicate matters for now. Add this to the declarations.

    if lastenemy < gl.time():
        enemies.push(enemy { sincos(rnd(360)) * worldsize * 2.0, enemymaxhp })
        lastenemy += enemyrate
        enemyrate *= 0.999

    for(enemies) e:
        let playerdir = normalize(playerpos - e.pos)
        e.pos += playerdir * gl.delta_time() * enemyspeed
        for(bullets) b:
            if magnitude(b.pos - e.pos) < 1.0:
                e.hp = max(e.hp - 1, 0)
                b.pos = float2_x * worldsize * 10.0
        gl.color lerp(color_red, color_blue, div(e.hp, enemymaxhp)):
            renderpointytriangle(e.pos, playerdir)

    enemies = filter enemies: _.hp

During the frame, we use the above code to deal with enemies, which is very similar to bullets yet again. We spawn new enemies initially once per second. To compute a spawn location, take a random angle, convert to a vector, then offset that vector to somewhere outside the screen to have them flock in from all directions.

We do one special thing: we keep reducing the enemy rate subtly. This guarantees the game will get harder and harder and the player will guaranteed die eventually, it is just a matter of when.

Then we loop through all enemies, compute a vector towards the player, and move the enemy towards him. Of course, now we need to do collision detection between bullets and enemies, which we do in brute force manner by checking every bullet with every enemy. If the quantity of both stays low, this is ok, but for large amounts of objects this approach will eventually get too slow, requiring bucketing of objects in a grid. For now, this is ok. We check the distance between the center of the bullet and the center of the enemy, and consider it a hit if they are a generous 1 unit apart or less. Of course, we could check exact collision with the triangle, but besides being complicated, it might not even be desirable for gameplay.

If we have a hit, we reduce the HP of the enemy by one (we're using max to ensure it doesn't go below 0, you'll see why in a bit). We also need to "kill" the bullet when that happens, but since we already have code that removes bullets that leave the world, we are lazy and make use of that, by moving the bullet far from the center.

Then we render the enemy conveniently with our existing function. We do something fun here, letting the health of the enemy determine its color, from healthy blue to a dead red. We use lerp (linearly interpolate) of 2 colors. div is a useful function that does the same as / on 2 integers but gives a float result.

Then of course we need to cull dead enemies, which we do with a similar filter function. Culling all at once like we do here with bullets and enemies is nice, because culling during update can introduce subtle bugs.

We can't really play yet however, since we can't die...

6: Score, and Game Over

Now we have some loose ends to wrap up to call this thing a "game": we have to keep track of the player health and score (and highscore), and give him a start / game over screen in-between sessions.

To do that, we will have to be able to render text. First step towards doing that is loading up a font, right after gl.window:

check(gl.set_font_name("data/fonts/US101/US101.ttf") and gl.set_font_size(32), "can\'t load font!")

check is another useful function much like fatal, that ensures the first argument is true, and if not, exits the program with the given message.

To implement the functionality, we start with some new variables:

var playerhealth = 0.0
var score = 0
var highscore = 0
var playing = false

inside our frame, we then test our playing variable, and when true, run the game code we were running before, and if false, display the in-between screen, which simply says:

        let msg = "press space to play!"
        gl.translate (gl.window_size() - gl.text_size(msg)) / 2:
            gl.text(msg)
        if gl.button("space") == 1:
            score = 0
            playerhealth = 100.0
            playerpos = float2_0
            bullets = []
            lastbullet = gl.time()
            enemyrate = 1.0
            enemies = []
            lastenemy = gl.time()
            playing = true

The gl.translate centers our text using the handy gl.text_size which tell us the size of a string in pixels before we've even rendered it. gl.text then draws it. We then reset the game state when the game starts, and next frame the game will be playing.

To show the current state to the player, we use the simple:

    gl.text("health: {ceiling(playerhealth)} - score: {score} - highscore: {highscore}")

We call this as the very last thing in the frame, so it is visible regardless of whether the player is in-game or not, for simplicity. ceiling is useful since we'll be calculating the player's health in float, but want to show only the whole numbers. ceiling here is better than truncate, since we only want to show 0 when the player is truely dead.

                let playervec = playerpos - e.pos
                if magnitude(playervec) < 1.0:
                    playerhealth -= gl.delta_time() * 50.0
                    if playerhealth <= 0.0:
                        playerhealth = 0.0
                        highscore = max(highscore, score)
                        playing = false

Then finally, inside our for(enemies) e: loop, we add this bit of code which checks if this particular enemy is "touching" the player. If so, we subtract health for how long he is touching. If the player runs out of health, we update the high score, and revert back into non-playing mode.

Game Over, and Tutorial Over!

7: What's next?

We now have a very basic structure of a game. While it is not impressive, it does deal with all the typical things a game does, and thus extending it from here should be fun and easy. Some ideas of things you could add:

Also, at this point, our game has no sound at all. If you'd like to see how to add some ambient or enemy sounds, have a look at the use of the play_ and sound_ functions in tut_sound.lobster. This example also shows how to to dynamically adjust the volume and pause or resume individual sounds.

This tutorial has all game state in top level variables, for simplicity of explanation. If you want to have a look at what the code would be like if you instead stored all of it in classes, have a look at tut_obj.lobster.