Platformer engine cleanup and fixes

Where are we now?

So far, so good. But we can improve.

In my previous post, we started coding the very basics of movement and collisions. There we understood the general idea that, since we’re making a low resolution game, we might as well check for collisions at each pixel.

If you haven’t read the previous posts, go check them out.

What will we improve?

If you download the sample project (button above) you’ll notice a few things. There’s a bunch of unorganized scripts and objects. So the first thing we’ll do is a quick project organization and refactoring.

Also you’ll see that the project includes a pretty generic move_and_collide script but there’s no move_x and no move_y so we’ll create them instead.

Another thing that really puts me off is that we have wall_on_side and can_go_up scripts that really don’t mean much. It might be a good idea to make scripts with sensible defaults that we can override whenever we call them.

One more horrible sin is that I’ve mixed the player states in the platforming engine folder. What was I thinking?

Original project layout.
A better project layout.

First thing I made is creating new folders for scripts and objects.

SCRIPTS
- Platformer Engine
- Player States
- Drawing

OBJECTS
- Entities
- Collisions

Sub pixel drawing

I want to focus for a second on the Drawing folder and the get_yoffset_on_slopes. That script is responsible for subpixel vertical drawing of the player sprite on slopes.

Since I want to focus more on the collision side of the engine, I’ll temporarily stop using subpixel drawing. The whole game will draw pixel perfect stuff without sub-pixel calculations from now on.

We’ll get back to subpixel drawing in later articles (and you’ll see why I left it out today).

So first things first, I completely delete the player draw event. This will ensure that GameMaker Studio 2 will draw the player sprite at the player’s x and y coordinates.

No more subpixel shenanigans. For now.

Replace magic numbers

There’s no place for magic in our code.

Let’s create some variables so we can safely delete magic numbers from code (it’s a good idea to avoid them).

Magic Numbers

A magic number is basically a hard-coded value that might change at a later stage. Since it has the chances of changing at a later stage, it can be said that the number is hard to update. The use of magic numbers in programming refers to the practice of coding by using particular numbers directly in the source code.

// Place these in the player's create event.
jump_speed          = 4
walk_speed          = 2.23
grav                = 0.17
terminal_velocity   = 3

Substitute the correct variable in the correct place:

  • walk_speed is in the player’s step event.
  • jump_speed (remember the negative sign) is in the playerStateGround script.
  • both the grav and terminal_velocity are to be placed in the playerStateAir script.
You should have something similar to the above picture. No more magic numbers.

Bugs. Squash’em.

What a funny jump behavior that is… not.

If you run the game now, you’ll notice something weird. Admittedly it might take a while for you to notice so I recorded a gif, but the bug is there: the very first jump you make from the top of the one-way block, is higher than subsequent jumps.

You can clearly collide with the ceiling during the first jump.

This bug was always there but we just didn’t notice because we used a different speed for jumping. Apparently using a jump_speed = 4 and a grav = 0.17 makes the bug very apparent to us; why though?

The problem lies in how the code runs; the player instance keeps a yvel_fract of -0.47 after landing on the block. It shouldn’t. Instead it should set its vertical speeds to 0 when it lands.

It should set the speeds to 0… shouldn’t it?

You might say that this is exactly what the move_and_collide script does in the vertical movement portion, and it’s true; but in this specific instance, that piece of code isn’t run because it lands on the block exactly with a vertical speed of 0, so it has already exhausted its vertical movement cycle and collision checks. It will never set the speeds back to 0. Of course it will set the speeds to 0 when jumping again (due to grav), so the subsequent jumps will start from a yvel_fract of 0. That’s why only the first jump is off.

It’s a simple fix though. Let’s set the vertical speeds to 0 when we first enter the playerStateGround.

From now on, as soon as the player changes state to playerStateGround, its vertical speeds will be absolutely 0.

There’s also another nasty bug which has to do with the way the player stands on one-way platforms.

The player gets stuck 1px inside a solid when he tries to jump down.

I’m not sure if you can see it from the gif, but if you try to jump down from the one-way platform, you get stuck in the solid block.

We have to put in place more checks to ensure that the player is standing only on solid walls.

The culprit is in the playerStateGround() script.

// playerStateGround() Before
if keyboard_check_pressed(ord("X"))
{
	if keyboard_check(vk_down) && on_jumpthrough()
	{
		y++
	}
	else
		yvel = -jump_speed
	
	state_current = playerStateAir
	exit
}
// playerStateGround() After
if keyboard_check_pressed(ord("X"))
{
	if keyboard_check(vk_down) && on_jumpthrough() && !on_wall() && !on_slope()
	{
		y++
	}
	else
		yvel = -jump_speed
	
	state_current = playerStateAir
	exit
}

Notice all the checks we’re making to ensure the correct behavior of the player on one-way platforms.

Refactoring for flexibility

Let’s make our code more flexible with some refactoring

Start from what you would like to type. For example I’d like to be able to type move_x( xvel ) and be able to move the player on the x axis. But what if we need to indirectly move something else, like another instance? Say you want to type move_x( xvel, instance ). Maybe we could make that instance parameter optional and default to self. And what if we want to programmatically enable or disable the slope ability for certain instances? Maybe something more like move_x( xvel, do_slope = true, instance = self ). By the way, we could add a return value. If the instance successfully moved, we could return true, otherwise we could return false.

Suddenly our move_x script became so much more flexible and versatile. You can move an instance with sensible defaults but now you can apply movement to other instances as well without waiting for their event to fire or messing with their instance variables. Do you see why we needed this refactoring before writing about moving platforms?

///@func move_x(xvel, [do_slope, instance])
///@arg xvel
///@arg [do_slope]
///@arg [instance]

// Apply default arguments
var _xvel    = argument[0]  // Notice how _xvel becomes a local variable
var _xdir    = sign(_xvel)  // Also _xdir becomes a local
var instance = self
var do_slope = false

// Override variable number default arguments
switch (argument_count)
{
  case 3: instance = argument[2];
  case 2: do_slope = argument[1];
}

with(instance)
{
  // Movement/Collision X
  repeat(abs(_xvel))
  {
    if !place_meeting(x + _xdir, y, oWall)
    {
      x += _xdir

      if do_slope && !slope_move(_xdir)
          return 0 // We couldn't move on a slope
    }
    else
      return 0 // If we collided with something, return 0
  }
}

return true

We’re going to apply the same logic to the move_y script but first create a wall_above() script.

///@func wall_above()

return collision_rectangle(bbox_left, bbox_top - 1, bbox_right, bbox_top - 1, oWall, false, true)
///@func move_y(yvel, [instance])
///@arg yvel
///@arg [instance]

var _yvel    = argument[0]
var _ydir    = sign(_yvel)
var instance = self

switch (argument_count)
{
    case 2: instance = argument[1];
}

with(instance)
{
    repeat(abs(_yvel))
    {
        // Going down
        if _ydir
        {
            if !on_ground()
            {
                y += _ydir
            }
            else
                return 0
        }
        // Going up
        else
        {
            if !wall_above()
            {
                y += _ydir
            }
            else
                return 0
        }
    }
}

return true

Now that we split the movements along their axis, we must implement the new scripts. Find the line where there’s the move_and_collide() script and replace it with the following lines

round_vel()

// Let the instance decide what to do when it can't move
if !move_x(xvel, true)
{
    xvel       = 0
    xvel_fract = 0
}

if !move_y(yvel)
{
    yvel       = 0
    yvel_fract = 0
}

You might have noticed that I created the round_vel() script as well. That’s because move_x() and move_y() only accepts integer speeds. We need to round velocities before using them.

///@func round_vel()
///@desc Round the xvel/yvel while keeping track of fractions

xvel_fract += xvel;
xvel        = round(xvel_fract);
xvel_fract -= xvel;

yvel_fract += yvel;
yvel        = round(yvel_fract);
yvel_fract -= yvel;

One last bit of code we need to put in place is the slope_move( _xdir ) script that we call inside the move_x().

///@func slope_move( xdir )
///@arg xdir

var _xdir = argument[0]

// Inside a slope (must go up)
if collision_point(x, y - 1, oSlope, true, true)
{
    // If we cannot move up, we must go back.
    if !move_y(-1)
    {
        x -= _xdir
        return 0
    }
}

// On a slope going down
if !on_ground() && collision_point(x, y + 1, oSlope, true, true)
{
    move_y(1) // Hey there! Hello move_y, my friend!
}

return true

That’s all, folks!

Let’s wrap it up.

  • We deleted the draw_event of the player so we could test the game without the subpixel drawing shenanigans.
  • We replaced magic numbers with variables.
    • In doing so we discovered a sneaky bug.
    • Which we fixed in the playerStateGround()
    • Along with another fix for one-way platforms.
  • We split the move_and_collide() script into move_x() and move_y() using optional parameters with sensible defaults and writing those scripts so they can return a value to the caller.
  • We rounded the velocities before calling the movement and collision scripts with a round_vel() script
  • We extracted the move_slope() script and made it return a value.
  • Overall we paved the way for great things to come (moving platforms, conveyor belts, knock-back and more).

As you can see it’s always good to go back and revisit your code. These sort of bugs can give you lots of headaches down the road. Also with these refactoring we made it very easy to make edits to single scripts without creating a mess. Hope you found this article useful and gained a small insight into how I write my prototype code. If you’d like to download the final project, click the download button above.

Let me know what you think about it and feel free to leave comments.

Buy Me a Coffee at ko-fi.com

7 thoughts on “Platformer engine cleanup and fixes”

  1. FYI, your code lead to YoYoRunner app crash on macOS. GMS2 editor do not report any error, runner just crash. On Windows it works ok, as expected. Probably you discovered gamemaker engine implementation bug on macOS.
    This happens in player step -> move_x -> slope_move -> move_y, when we return zero from move_y, trying to go up on slope, but get stopped by solid object above us. Exact crash happens on line 12 slope_move when we subtract _xdir from x to go back.

    How far you plan to go? Do you plan to implement one way slopes? Solid platforms that can push and move player? Pushable (by player & platforms & other boxes) boxes? Combination of all of this? Boxes on boxes on moving platforms that can be properly pushed by other platforms and also push player, etc.
    And (most importantly) all of this properly synchronized with sub-pixel movement of sprites? Without noticeable jitter? Keeping good performance?
    I tried do this, but gave up. Personally, I found this unsolvable in Gamemaker. Integral nature of collision resolution in Gamemaker is not well matched with smooth sprite movement.
    Good luck with your project, it is interesting to observe its progress.

    BTW, comment in on_ground script say: “return instance_id of the colliding ground object or noone if not colliding”, but I think it always return boolean value, true or false.

    Reply
    • Hey dmitry, thanks for stopping by and letting me know about it. Have you already opened a ticket with YoYo? otherwise I could do it, let me know.

      Regarding the project, I think moving platforms pushing multiple stacked (and pushable by player) objects could be within my reach. I might try the one way slope thing as well but it’s gonna take some time (because of my day to day job). This weekend I’ll give it a shot.

      As you noticed yourself, when dealing with something like that (moving stuff mainly) it’s important to take care of the order of execution so I will spend some extra time dealing with those issues. For that reason though, the subpixel movement, although present on my todo list, will be postponed a little. I want to get the collisions and the execution order right. Then I’ll deal with the subpixel drawing. I think it’s completely possible to do that and I’m committed to find a (sane) way to do all that.

      As for the wrong comment in the script, you’re right, it returns true/false. I fixed the download link with the correct code. Thanks for noticing it; much appreciated. Please let me know if you notice other oddities, typos or plain wrong code. Thank you

      Reply
  2. >>>Have you already opened a ticket with YoYo?<<>>I want to get the collisions and the execution order right. Then I’ll deal with the subpixel drawing.<<<
    Ok. In my opinion when we dealing with moving stuff which can push & carry each other, proper subpixel movement is the most hardest part (and the challenge, present only in Gamemaker, unfortunately).

    Reply
  3. Oh, the site broke my reply, so I repost it again.

    “Have you already opened a ticket with YoYo?”
    No. If you allow me to send full project (as a repro case) to YoYo I will do that.

    “I want to get the collisions and the execution order right. Then I’ll deal with the subpixel drawing.”
    Ok. In my opinion when we dealing with moving stuff which can push & carry each other, proper subpixel movement is the most hardest part (and the challenge, present only in gamemaker, unfortunately).

    Reply
    • Of course you can attach the full project; it’s basically public. I’d open the ticket myself but I don’t have a Mac and I wouldn’t know what details to provide.

      I’ll try very hard to get all those subpixel movement right in the next iterations. We’ll see how it goes.

      Reply

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.