Thread Rating:
  • 1 Vote(s) - 5 Average
  • 1
  • 2
  • 3
  • 4
  • 5
GX Platformer Tutorial
#1
I've had a couple of requests to do a tutorial for building a 2D platformer in GX

What is GX? 
Well, it's a game engine that I started work on a few years ago, back in the qb64.org days. The first post was back in December of 2021.  In the process of working on several 2d game projects I was noticing that I was needing to write a lot of game "plumbing" for each project.  I wanted to see if it would be possible to create a generic game API and engine for QB64 that could support a number of different types of 2D games (platformers, top down, isometric strategy, etc.).  GX was the result of that effort.

The goal of the project is to create a flexible, event-based game engine. Based on your game requirements you can use as much or as little of it as you need, but the engine will take care of the main tasks of managing the game loop and screen buffering for the display.  The current version has support for:
  • Scene(viewport) management
  • Entity(sprite) management
  • Tiled map creation and management
  • Bitmap font support
  • Collision detection
  • Basic physics/gravity
  • Device input management
  • Keyboard, mouse, and game controller
  • Interactive debugging
  • Export to Web
That last bit, "Export to Web" got the most initial interest and was the starting point for what would eventually become QBJS.

The Tutorial
My idea for the tutorial is to build this little game below over the course of several posts, starting from scratch and building it step-by-step, highlighting the various features of the engine along the way.  If you want to try it out now press play and use the arrow keys to move and space bar to jump:


About the Source
This sample includes all of the code needed for building the game in QB64, so you can also download the attached simple-platformer.zip, export the contents and build it with QB64(PE).  Strictly speaking, the contents of the "lib" directory are only needed for building the game in QB64 as QBJS has the GX engine already included.  All of the included artwork is from opengameart.org.

I would love to have feedback along the way (positive or negative).  I know there are a lot of experienced game developers here and would value any experiences and suggestions that could make the engine better.


Contents


Attached Files
.zip   simple-platformer.zip (Size: 86.85 KB / Downloads: 597)
Reply
#2
@dbox - this is awesome. seriously. your work is unsung.

You should make more tutorials on various other things with GX to get it some momentum.

Here are some good types to make that Construct3 has:
https://www.construct.net/en/make-games/free-trial

I think if you scroll down you'll see what I mean.

Confession: I have always wanted to dig into GX but haven't had the time/motivation/etc. That said you making this tutorial makes it a hell of a lot more likely that I would.

Perhaps you should consider creating/hosting a simple gx website in github pages using SSR tools to showcase what is made, your tutorials etc.

All of the smartest people I know are not marketing / pumping out this kind of thing. I'd point you to just looking at the repo I made for QB64PE web as a good starting point, and would be happy to help you make assets and maintain GX web with you.

Anyway, your work is exemplary, excellent, exciting, and ... i can't think of another positive adjective for the last e. But it is!

Smile

Heart
grymmjack (gj!)
GitHubYouTube | Soundcloud | 16colo.rs
Reply
#3
Thanks @grymmjack!

That's a nice list of starter projects on the Construct3 site and I agree that it would be good to add to the library of GX samples available.

I like the work you did on the QB64PE landing page, simple, nice and clean.  I'm open to any help offered for the project!
Reply
#4
Part 1 - The Basics
Before we start coding, let's look at a few of the main concepts behind the engine...

The World
As GX is designed with 2D games in mind, the world can be imagined as a two-dimensional plane that expands out forever in every direction (north, south, east and west). Positions in this flat expanse are identified by X and Y pixel coordinates. Negative coordinates indicate positions north or east of the universe center (0, 0).
   

The Scene
The scene is the player's viewport into the vast expanse of the underlying world. While the world is infinite, the view into this world is a fixed size and defines the part of the world we see on the screen. So, for example, if we create a new scene that is 320 pixels x 200 pixels in size, we would initially see any content in the world that is between world coordinates (0,0) and (320, 200).

The scene itself has an X and Y position and can be "moved" around the world.  Only the portion of the world within the boundaries of the scene will be rendered.  In our simple side scroller example the X position of the scene is changed as the player moves through the map:
   

Entities
An entity is any object that can be placed in the world. An entity could be a spaceship, an enemy monster, a tree, an extremely athletic plumber... anything that needs to be in a certain place in the world. Entities have X and Y positions that indicate their location in the world. Movement can be indicated by setting an entity's X and/or Y velocity. Entities can be visible or hidden and can be rendered programmatically or have their appearance automatically rendered from a sprite sheet image, like the one we are using in our sample game:
   

We'll go deeper and cover more concepts later, but that's enough to get us started.

A word about coding conventions
All methods and constants in GX library are prefixed with "GX" to prevent naming collisions.  Methods are further grouped by concept.  For example, all of the methods that can interact with the scene are prefixed with "GXScene" (e.g. GXSceneCreate, GXSceneX, GXSceneMove).  A listing of the library methods can be found here (still a work in progress).

Ok, that's enough exposition for now.  Next time we'll fire up a new IDE window and start building our game...
Reply
#5
Part 2 - Let's Start Coding

Setting the Scene
For any GX project there are a couple of steps that we always need to do in order to use the library.
First, we need to include the GX engine code:
Code: (Select All)
'$Include: 'lib/gx.bi'

'$Include: 'lib/gx.bm'
This will include all of the GX game data structures and methods that we will need to build our game. All of our specific game code will be added between these two include statements.

Next, we need to define our game event method. The GX engine will call this method for all events that occur in the game... more on this later.
Code: (Select All)
'$Include: 'lib/gx.bi'

Sub GXOnGameEvent (e As GXEvent)
End Sub
'$Include: 'lib/gx.bm'

Now we need to create our scene with GXSceneCreate. This is usually the first step in the setup of our game. Let's create a new scene that is 256 pixels by 144 pixels.
Code: (Select All)
'$Include: 'lib/gx.bi'

GXSceneCreate 256, 144

Sub GXOnGameEvent (e As GXEvent)
End Sub
'$Include: 'lib/gx.bm'

If you run the program at this point you will just see a small black screen.  So, let's add something there to look at...

Adding the Player
Let's create a new entity which represents our player character with the GXEntityCreate method:
Code: (Select All)
...
GXSceneCreate 256, 144

' Create the player
Dim Shared player As Long
player = GXEntityCreate("img/character.png", 16, 20, 4)
...
The first argument references the spritesheet image which should be used for the entity display.  The next two arguments define the width and height of the entity, respectively, and the last argument indicates how many frames of animation to expect.  Let's look a bit more closely at the character.png to see how GX expects spritesheets to be constructed...
     
As you can see from the image above, this spritesheet contains four different animation sequences with each distinct sequence placed on a separate row in the spritesheet image.  Each animation sequence has four frames of animation.

In order to see what affect this has had we need to tell GX to start handing game events and display.  We do this by calling the GXSceneStart method:
Code: (Select All)
...
' Create the player
Dim Shared player As Long
player = GXEntityCreate("img/character.png", 16, 20, 4)

GXSceneStart
...

If you run the program now you will see our little guy in the upper left corner.  Since we haven't yet specified any position for our player entity it has defaulted to (0, 0).  We can also see that the sprite image has defaulted to the first frame of the first animation sequence.
   

This is way too small though, let's scale it up a bit.  GX has this scaling built in for us.  We can do this by adding a call to GXSceneScale right after we create the scene:
Code: (Select All)
'$Include: 'lib/gx.bi'

GXSceneCreate 256, 144
GXSceneScale 2

...
This will tell the engine to scale the entire scene up to two times its original size and start to give us that retro pixel art look.  While we're in there, let's also reposition the player character.  We can do this with a call to the GXEntityPos method:
Code: (Select All)
...

' Create the player
Dim Shared player As Long
player = GXEntityCreate("img/character.png", 16, 20, 4)
GXEntityPos player, 120, 75

GXSceneStart
...
The first parameter is the handle to the player object that was returned from the call to GXEntityCreate.  This will be the case or all of the other GXEntity* methods.  The next two parameters specify the x and y position of the entity.  This will cause the upper left corner of the entity to be positioned at the indicated coordinates.
   

Ok, this is great and all, but a game needs to be more than a static image, next time we'll start tackling some movement and talk about game events...
Reply
#6
This is pretty cool! I may eventually attempt to use this to port the Commodore 64 version of Lode Runner (with in-game level editor) to QB64PE. This could also be a good platform for porting Atari 2600 Pitfall or Pitfall II: Lost Caverns or Broderbund's Spelunker.
Reply
#7
Thanks @madscijr, I look forward to seeing your creations.
Reply
#8
Part 3 - Game Events & Movement
Before we start adding movement to our game we need to first understand the basics of the game event model in GX.  And to understand the game event model let's first look at the game loop.

An important aspect of any game is the game loop.  The pseudo-code for a typical game loop looks something like this:
Code: (Select All)
Initialization
Do
    Input Handling
    Game Logic / Updates
    Physics & Collision Detection
    Rendering
    Timing/Frame Regulation
Loop
  • Initialization
    This is where the game sets up everything it needs, like loading assets, initializing variables, and preparing game objects.
  • Input Handling
    The loop checks for user inputs (keyboard, mouse, controller) and processes them so the game can react accordingly.
  • Game Logic/Updates
    Here, the game updates the state of the world based on inputs, AI behavior, physics, and other rules of the game.
  • Physics & Collision Detection
    The loop calculates object movements and interactions, ensuring things like gravity, collisions, and object responses are handled correctly.
  • Rendering
    The game draws the updated world to the screen. This includes rendering characters, backgrounds, UI elements, and visual effects.
  • Timing/Frame Regulation
    To ensure the game runs at a consistent speed, the loop manages time, often using a fixed or variable timestep to control updates and rendering.

You'll notice in the examples shown so far there is no main loop in our actual game code.  That is because the GX engine is taking care of a lot of the heavy lifting here for us.  Instead, the engine sends events to our game at various points so we can implement the desired behavior that is specific to our game.  It does this by calling the GXOnGameEvent method that we added at the beginning of Part 1.

Ok, getting back to our code, if we want to move our little player character when the user presses certain keys we will need to handle the Update event.  Let's add a Select...Case statement to our GXOnGameEvent to set ourselves up for handling multiple events and add a Case statement for the GXEVENT_UPDATE event type.  To keep things organized, let's call a new OnUpdate method when the event is fired.
Code: (Select All)
...
Sub GXOnGameEvent (e As GXEvent)
    Select Case e.event
        Case GXEVENT_UPDATE: OnUpdate e
    End Select
End Sub

Sub OnUpdate (e As GXEvent)
End Sub
...
 
Now, let's add the logic in our new OnUpdate method to make the player move left or right when the left or right arrow keys are pressed:
Code: (Select All)
...
Sub OnUpdate (e As GXEvent)
    If GXKeyDown(GXKEY_LEFT) Then
        GXEntityVX player, -40

    ElseIf GXKeyDown(GXKEY_RIGHT) Then
        GXEntityVX player, 40

    Else
        GXEntityVX player, 0
    End If
End Sub
...
The GXKeyDown function will return true (-1) if the specified key is currently being pressed and false (0) when it is not.  The GXEntityVX sets the entity's X or horizontal velocity.  A negative value will indicate movement to the left. A positive value will indicate movement to the right.  The value passed for the velocity is in pixels per second.

Ok, we're starting to get somewhere now, we can move our player.  However, he just seems to be sliding around without moving his feet and he doesn't change direction.  So, let's add some animation.  First let's add some constants to define our left and right animation sequences that we looked at in Part 2:
Code: (Select All)
...
'$Include: 'lib/gx.bi'
Const MOVE_RIGHT = 1
Const MOVE_LEFT = 2

GXSceneCreate 256, 144
GXSceneScale 2
...
Then we'll add a call to GXEntityAnimate to show the appropriate animation when the user presses a direction key.  The third parameter to this method indicates the speed of the animation in frames per second.
Code: (Select All)
...
Sub OnUpdate (e As GXEvent)
    If GXKeyDown(GXKEY_LEFT) Then
        GXEntityVX player, -40
        GXEntityAnimate player, MOVE_LEFT, 10

    ElseIf GXKeyDown(GXKEY_RIGHT) Then
        GXEntityVX player, 40
        GXEntityAnimate player, MOVE_RIGHT, 10

    Else
        GXEntityVX player, 0
        GXEntityAnimateStop player
    End If
End Sub
...

Now if you run the program you will see that our player now appears to be walking left or right based on the keys pressed.  When the keys are released he stops and the walk animation is stopped since we called the GXAnimationStop method.


Attached Files
.zip   part3.zip (Size: 49.91 KB / Downloads: 356)
Reply
#9
Part 4 - Maps
Ok, so far we have just been walking around in the dark.  Let's look at creating an environment for our player to inhabit.  We'll start by creating a map.

GX has pretty robust support for tile-based maps. What are tile-based maps? Well, they are used in many 2D games to be able to construct large worlds from a relatively small set of reusable image tiles called a tileset.  For our game sample we will be using the following tileset image:
   

This tileset image is made up of 16x16 image tiles.  Each tile in the set is assigned a number based on its position:
   

A map, then, is made up of a collection of these tiles arranged in a grid.  If we want to create a map based on this tileset that fills the screen we can determine the dimensions of the map by dividing the screen dimensions by the tileset size.  Our screen size is 256x144. Dividing this by the tileset size 16x16 we get a map that has 16 columns x 9 rows of tiles.

In order to make creating and maintaining maps a bit easier, GX includes a Map Maker application.  Originally, this was built with InForm and the source is included in the GX github project.  Going forward, there is a web-based version.

We'll come back to this later but first, let's look at what's involved in creating a map programmatically using the GX API.  Understanding this will be important if we want to load map data from an existing source or if we want to get into more advanced procedurally generated maps.  (An example of this is the Wave Function Collapse POC that I hope to get back to at some point.)

Picking up where we left off in Part 3, let's create a new method to set up our map:
Code: (Select All)
Sub LoadMap
    GXTilesetCreate "img/tileset.png", 16, 16
    GXMapCreate 16, 9, 1

    GXMapTile 0, 2, 1, 3
    GXMapTile 0, 3, 1, 15
    GXMapTile 0, 4, 1, 15
    GXMapTile 0, 5, 1, 15
End Sub
Before creating our map we need to first load the tileset we described above with the GXTilesetCreate method.  The first parameter should indicate the image file which contains the tileset. The next two parameters indicated the width and height of the tiles in the set. Then we create a new map with the GXMapCreate method. This method expects us to pass in the number of columns, rows, and layers the map should contain. We'll keep it simple for now and start with a single layer.

The next set of calls to GXMapTile will place a tile in the map at the specified column, row and layer.  The last parameter is the tile id which is derived from its position in the tileset image.

Let's call our new method after we initialize the scene:
Code: (Select All)
...

GXSceneCreate 256, 144
GXSceneScale 2

LoadMap

...

If we run the program now we'll see the beginnings of our map:
   

This would be a bit tedious to add an individual call to GXMapTile for every single tile in the map.  So, let's add some basic map data:
Code: (Select All)
...

mapLayer1:
Data  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
Data  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
Data  3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1
Data 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,13
Data 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,13
Data 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,13
Data 15, 0, 0, 0, 0, 0, 0, 1, 3, 0, 0, 0, 0, 0, 0,13
Data 15, 0, 0, 0, 0, 0, 0,13,15, 0, 0, 0, 0, 0, 0,13
Data 28, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,30

Sub GXOnGameEvent (e As GXEvent)
...

Then let's update our LoadMap method to load the map tiles from our data section:
Code: (Select All)
Sub LoadMap
    GXTilesetCreate "img/tileset.png", 16, 16
    GXMapCreate 16, 9, 1

    Dim As Integer col, row, tile, layer
    For layer = 1 To GXMapLayers
        For row = 0 To GXSceneRows - 1
            For col = 0 To GXSceneColumns - 1
                Read tile
                GXMapTile col, row, layer, tile
            Next col
        Next row
    Next layer
End Sub

That's starting to look more interesting:
   

Let's add some more detail now by adding another layer of tiles.  First add the additional layer data:
Code: (Select All)
...

mapLayer2:
Data  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
Data  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
Data  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
Data  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
Data  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
Data  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
Data  0, 0,53,54, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
Data  0, 0,65,66, 0, 0, 0, 0, 0, 0, 0, 0,50, 0, 0, 0
Data  0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0

...

Then adjust the call to GXMapCreate in our LoadMap method to initialize two layers:
Code: (Select All)
...
    GXMapCreate 16, 9, 2
...

Here is the completed exercise:


Now we have the beginnings of an interesting environment for our player.  However, there are two issues that become apparent...  He can walk right through walls and seems to be floating in the air.

In part 5 we'll look at addressing this with collision detection.


Attached Files
.zip   part4.zip (Size: 82.24 KB / Downloads: 265)
Reply
#10
Part 5 - Collision Detection
So, if we want to prevent our player character from being able to walk through walls, we need to be able to detect when a collision has occurred.  

GX has built-in support for two different types of collision detection. The first, which we'll look at now, handles the scenario when an entity collides with a given tile position on the map.  The second, which we may cover in the future, handles the scenario when an entity collides with another entity.

To implement tile collision detection, we need to handle a new event (GXEVENT_COLLISION_TILE).  So, let's add a new Case statement for this event to our main event function GXOnGameEvent and create a new method for this logic:
Code: (Select All)
...
Sub GXOnGameEvent (e As GXEvent)
    Select Case e.event
        Case GXEVENT_UPDATE: OnUpdate e
        Case GXEVENT_COLLISION_TILE: OnTileCollision e
    End Select
End Sub
...
Sub OnTileCollision(e As GXEvent)
    If GXMapTile(e.collisionTileX, e.collisionTileY, 1) Then
        e.collisionResult = GX_TRUE
    End If
End Sub
...
GX is going to pass us the handle of the entity and information about the tile with which the entity is currently intersecting.  It does this by setting values in the GXEvent object that is passed to our event handler.  So far, we have only been concerned with the event type. For the tile collision event GX will also set the "entity" attribute to the handle of the entity involved in the collision as well as the position of the tile in the "collisionTileX" and "collisionTileY" attributes. The GXEvent object has an additional attribute which we can use to communicate back to the engine whether a collision was encountered, "collisionResult".

Since there is currently only one entity (the player), we need only check the map to see whether there is a tile present at the location by calling the GXMapTile function.

If we run the program now, we'll see that the player will not be able to walk past the walls on either side of the screen.
   
Ok, well that fixed one of our problems, but our player still seems to be walking through the air.  Now that we have our collision detection in place, we can turn on the gravity and our player won't fall through the floor. In GX, gravity is applied at the entity level.  We can apply this to our player by adding a call to the GXEntityApplyGravity method:
Code: (Select All)
...
' Create the player
Dim Shared player As Long
player = GXEntityCreate("img/character.png", 16, 20, 4)
GXEntityPos player, 120, 75
GXEntityApplyGravity player, GX_TRUE
...

This will now cause our player to fall down to the ground tiles:
   
You may also notice that the player is able to walk in front of the tree and barrel tiles.  This is because we are only checking for collisions on layer 1.

Now we can fall, but we're missing a key element of any good platformer... the jump.  We can add this by triggering a sudden upward velocity when the user presses the spacebar by adding the following to our OnUpdate method:
Code: (Select All)
...
    Else
        GXEntityVX player, 0
        GXEntityAnimateStop player
    End If
   
    If GXEntityVY(player) = 0 And GXKeyDown(GXKEY_SPACEBAR) Then
        GXEntityVY player, -120
    End If
End Sub
...
In addition to checking to see if the spacebar is pressed, we also want to make sure that the player's current Y velocity is 0.  We don't want him to be able to be able to start a jump when he is already jumping or falling.

Putting it all together, we can now walk and jump around our little map!


Next time we'll look at refining the controls and movement.


Attached Files
.zip   part5.zip (Size: 82.35 KB / Downloads: 252)
Reply




Users browsing this thread: 1 Guest(s)