Platformer engine cleanup and fixes
Some adjustments required for GameMaker Studio 2.3
The following article is for pre 2.3 versions of GameMaker Studio 2. It doesn’t take advantage of functions, chained accessors, structs and other new features of GML/GMS2.3+ IDE. Unfortunately I’m unable to update the series for the foreseeable future, but downloading and importing the final project inside the new IDE should trigger the conversion.
It will run correctly. From there you could refactor and clean the code a bit to suit your taste, I guess.
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.
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?
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’sstep event
. jump_speed
(remember the negative sign) is in theplayerStateGround
script.- both the
grav
andterminal_velocity
are to be placed in theplayerStateAir
script.
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.
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.
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.
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 intomove_x()
andmove_y()
using optional parameters with sensible defaults and writing those scripts so they canreturn
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.
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.
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
>>>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).
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).
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.
This is awesome; thanks for all the time you put in this!
Thanks! I wish I could put more time into it 🙂 Anyway soon there’s gonna be a new article 🙂
Thanks for doing all this – I’m learning loads of great stuff!
I found one little bug in this – if you take a slope block and stretch it vertically so it’s extra steep, then try walking up it, you end up kind of walking into it instead of sticking to the top. This looks to be because in slope_move(), we check once if we’re “in the slope”, and then just move up a pixel. This isn’t enough to push you to the top of the steep slope though, so all I did was change the first if to a while.
This might cause problems later, but for now it’s fine. Anyway, this behaviour is a bit weird because now you whiz up the slope really quickly, so later down the line I doubt anyone would want this!