Guide to develop low resolution 2D platformers with smooth movement and pixel perfect collisions in GameMaker Studio 2 (with slopes)

Low resolution is great. It saves CPU power, especially with the collision code I talk about in this article, and it makes it easier to work with the project (like when buildinghuge rooms or making edits to sprites). You’ll see that sometimes there’s little to no reason to scale up your sprites. It’s just a waste of resources.

With the collision code I use objects move by whole pixels. This makes for perfect collisions but it usually leads to jittery movements. Luckily there’s a simple solution.

The Movement and Collision Code

This is the most practical collision code I ever came across on the web. I read about it some time ago on Zack Bell‘s blog and I subsequently adapted it slightly to suit my needs. It basically remains the go-to code for 2D low res platformers. It’s like… unbeatable. Think holy grail of platformer movement and collision.

I’m using the following hierarchy.

 obj_solid
  |
  |_ obj_slope
|_obj_slope_rx
|_obj_slope_lx

You can use a different hierarchy for your collisions, just adapt the scr_platformer_move code. I’m using fall through platforms so I use the obj_solid_top.

This script must be called from the create event of your active/moving objects.

/// @desc       Initialize Platformer Vars
/// @func       scr_platformer_init()

/// This script is usually called in the create event

// Initialize the variables used for movement code
xVel = 0                // X Velocity
yVel = 0                // Y Velocity

xVelSub = 0             // X Sub-pixel movement
yVelSub = 0             // Y Sub-pixel movement

This code should run at the end of your movement velocities calculations. Ideally at the end of your object’s step event (normal step event is fine).

(open in pastebin)

Collision Masks

It’s of fundamental importance that you take extra care when dealing with collision masks. Make sure they behave the expected way especially when flipping your objects around. Most of the times the error lies in the origin or in the symmetry of a mask.

Wrong Collision Mask

Here’s a sample of a wrong collision mask. Counter intuitively I placed the origin in the exact middle of the mask. It will result in asymmetric mask behavior when mirroring it (i.e. when turning left or right in the game). This mask will create collision issues and probably get objects stuck inside walls or slopes.

This mask is wrong.

Correct Collision Mask

It took me a while to understand how the origin pointer looks and behaves. This is a centered, symmetric collision mask… I’ll be honest: it absolutely doesn’t look like that to me. But trust me, this is the right one.

This mask is correct.

Let's fix the jittery movement!

It’s all about surfaces. We need to:

  1. Disable the automatic drawing of the application surface.
  2. Resize the application surface to the correct, hi resolution size.
  3. Draw our sprites with sub-pixel offsets.
  4. Draw the stretched application surface manually in a post_draw event.

Considerations

Can you see what’s going on here? Objects still move by whole pixels. Their collisions are still being calculated for whole numbers only. Still, we draw the sprites with sub-pixel precision!

The loops for collision checks have to run for very low numbers/distances. This means ultra-smooth movement, ultra high performances and very low disk/ram resource usage (compared to up-scaled pixel art).

Still jittery on slopes? Let's fix it

If you download the attached project you’ll see how I solved the slopes jittery movement.

I’m using simple trigonometry to find the Y position given the X position on a slope. I’m still using whole pixels to compute collisions but I use the following snippet just to draw the sprite of the player.

// Check for slope offset
slope = collision_rectangle(bbox_left, bbox_bottom +1, bbox_right, bbox_bottom + 1, obj_slope, true, true)

if slope
{
    var slope_height    = abs(slope.bbox_bottom - slope.bbox_top)
    var slope_base      = abs(slope.bbox_right - slope.bbox_left)
    var angle           = arctan(slope_height / slope_base)

    // Slope to the right
    if object_is_ancestor(slope.object_index, obj_slope_rx)
    {
        if bbox_right < slope.bbox_right slope_spr_y = slope.bbox_bottom - (bbox_right + xVelSub - slope.bbox_left) * tan(angle) else slope_spr_y = slope.bbox_top } // Slope to the left else if object_is_ancestor(slope.object_index, obj_slope_lx) { if bbox_left > slope.bbox_left
            slope_spr_y = slope.bbox_top + (bbox_left + xVelSub - slope.bbox_left) * tan(angle)
        else
            slope_spr_y = slope.bbox_top
    }
}
else
    slope_spr_y = 0    // Not on slopes
And so in the draw event of the player I use the following:
// Slope Y Position
if (slope_spr_y != 0)
    var yspr = slope_spr_y
else
    var yspr = y + yVelSub

draw_sprite_ext(sprite_index, image_index, x + xVelSub, yspr, image_xscale, image_yscale, 0, c_white, image_alpha)

Conclusions

This system might not be perfect and I’m open to new solutions. Let me know if you have a better system to obtain smooth movements using low resolution assets.

Advanced Animation Control in GameMaker Studio 2 – Method 1

Let’s say that you have a sprite with a complex animation (i.e. variable frame rate). As you can see from the following image, each frame will play at a specific time (I use a simple Photoshop script to export the frames, I’ll write an article about it later).

This is the folder’s content, exported from Photoshop. File naming scheme is important.

Continue reading Advanced Animation Control in GameMaker Studio 2 – Method 1

How I Scale Fuzeboy Resolution on Mobile and Desktop Devices

Fuzeboy’s still in development so it’s only natural that sometimes I take time to rewrite stuff, to fix things, to experiment and so on. We try, we break, we fix, we extend, we change. We evolve.
A scene from Fuzeboy. There’s only one way to view pixel art… and that is with pixel perfect scaling.
One issue we faced from the start, is the game resolution. What we knew was that we wanted pixel perfect scaling no matter what. Remember that this game will be both for mobile and desktop. Here’s my solution as of today.

The Ideal Game Resolution

Fuzeboy cares about its height. The width depends on the monitor aspect ratio (within reasonable limits). The ideal height of Fuzeboy is around 240px. Keep this in mind.

Pixel Perfect, Full Screen, No Black Bars (didn’t work)

This approach was the first I used. Since not every device has a vertical resolution that’s a multiple of 240px, I allowed different devices to show more or less “game world”. First I get the display vertical resolution. Then I cycle from 200px to 300px to see which one fits perfectly the device vertical resolution. If I don’t find a perfect fit, I give up and just use 240px.

Fuzeboy on Devices, method 1

DeviceResolution (vertical)Game Vertical ResolutionMultiplier
Amazon Kindle Fire 7″1024 x 600 (600)3002
Samsung S3 Mini800 x 480 (480)2402
Asus MeMO Pad 71280 x 800 (800)2004
This doesn’t work. It’s pixel perfect most of the times, there are no black bars… but you might end up seeing an unplayable 200px vertical resolution on a 7″ tablet.
We don’t want this to happen… do we?
Let’s have a look at an Amazon Kindle Fire 7″
This is better. Or… is it?
There’s a huge 100px difference in height from the Asus to the Kindle. The Fire users would have been able to see 50% more game world than the Asus players. That’s wrong. Especially since Asus has a higher screen resolution than the Amazon Fire.

Pixel Perfect, Black Bars (in use as of today)

First of all I decided to restrict the range of vertical resolution of the game. Now it’s from 230px to 260px. Still a 30px difference but it’s bearable. So I still ask for the device vertical resolution, of course. Then I check which number, from 230 to 260, fits best. By that I mean it either fits perfectly or has the lowest remainder. This is the initialization script. It goes inside the create event of the very first object created in the game. I also leave the view settings in the room editor as they are. Disabled. I enable them via code.
///scr_init_display()

// Let's disable the drawing of the App Surface
application_surface_draw_enable(false);

dw = display_get_width()        // Device Display Width
dh = display_get_height()       // Device display height
ar = dw / dh                    // Aspect Ratio

min_h   = 230                   // Minimum Height
max_h   = 260                   // Maximum Height
height  = min_h                 // We start from the minimum
fract   = frac(dh / height)     // This is the fractional part
mult    = floor(dh / height)    // This is integer multiplier

// We cycle from min_h to max_h
for (var h = min_h; h < max_h + 1; h++)
{   
    var new_fract   = frac(dh / h)
    var new_mult    = floor(dh / h)
    
    // If we have a lower remainder, we store
    // the multiplier and the height we're testing
    if new_fract < fract
    {
        fract   = new_fract
        height  = h
        mult    = new_mult
    }
}

// This will show you the found resolution in the debug console
show_debug_message("Found resolution: " + string(height))

// Width gets decided with a simple division
width = floor(dw / mult)

// And made divisible by 2
if width mod 2 != 0
    width--

/***************************************************
  SET THE VIEW AND THE PORT FOR ALL ROOMS
 ***************************************************/var i   = true;
var rm  = room_next(room);
while (i)
{
    room_set_view(rm, 0, true, 0, 0, width, height, 0, 0, width * mult, height * mult, 0, 0, -1, -1, -1)
    room_set_view_enabled(rm, true)
    if (rm == room_last)
        i = false
    else
        rm = room_next(rm)
}

// Resize the application surface
surface_resize(application_surface, width, height);

// Let the GUI layer be as big as the device screen
display_set_gui_size(dw, dh)

gw = display_get_gui_width()    // GUI width variable
gh = display_get_gui_height()   // GUI height variable

// We'll need these to figure out the touch commands coordinates
wscale = width / (dw / mult)
hscale = height / (dh / mult)

// Let's figure out the App Surface offset (we want it centered)
Xoffset = floor((dw - (width * mult)) / 2);     // Horizontal Offset
Yoffset = floor((dh - (height * mult)) / 2);    // Vertical Offset

/// Go Fullscreen on desktop
if os_type == os_windows
{
    window_set_fullscreen(true)
}
Then we need to draw the App Surface. So in the game controller object, I have the following code in a Post Draw event.
///Draw the App Surface with correct offset

// This line prevents strange artifacts in Fuzeboy.
draw_enable_alphablend(false);

// The real drawing.
draw_surface_ext(application_surface, Xoffset, Yoffset, mult, mult, 0, c_white, 1);
If I run the game on the Amazon tablet, the result is this:
The game now has a height of 260px

Fuzeboy on Devices, method 2

DeviceResolution (vertical)Game Vertical ResolutionMultiplierBlack bars top/bottom
Amazon Kindle Fire 7″1024 x 600 (600)260220px
Samsung S3 Mini800 x 480 (480)24020
Asus MeMO Pad 71280 x 800 (800)260310px
Now the game scales much better.

Let’s fix the Touch Controls.

Those touch buttons share the obj_touch parent so I can do this in the controller Begin Step event.
// Add these in the display init script
xoffsetmult = (Xoffset / mult)
yoffsetmult = (Yoffset / mult)
// Touch Controls
for (var dev = 0; dev < 4; dev++)
{
    touch_dev       = dev
    var _xpos = (device_mouse_x_to_gui(dev) / mult) + view_xview - xoffsetmult
    var _ypos = (device_mouse_y_to_gui(dev) / mult) + view_yview - yoffsetmult
    
    var this_button = instance_position(_xpos, _ypos, obj_touch);
    
    if this_button != noone
    {
        if device_mouse_check_button(dev, mb_left)
        { 
            with(this_button)
            {
                touch_press_action()
            }
        }
        
        
        if device_mouse_check_button_pressed(dev, mb_left)
        {
            with(this_button)
            {
                touch_pressed_action()
            }
        }

        
        if device_mouse_check_button_released(dev, mb_left)
        {
            with(this_button)
            {
                touch_released_action()
            }
        }
    }
}

The Future

As of today, I still experiment with the resolution of the game. I haven’t found a perfect way to scale low res pixel graphic fullscreen with no black bars and no distortion… simply because such a way doesn’t exist. Things change frequently around here, so we’ll see if I’m going to stick with this method or not. Feel free to let me know your ideas on this matter.

Devlog #1 – GameMaker: Studio new Project

This entry marks the first entry of a new Devlog Series.

I’m currently working on a couple of GameMaker projects and I thought it might be useful to document my development process. I’ve been inspired to do so by reading the Loadworld Devlog PT. 2 by @ZackBellGames

Continue reading Devlog #1 – GameMaker: Studio new Project

How I positioned the tileset in my tech demo

In the earlier tech demo, I had positioned a truckload of similar tilesets. Those bluish/greyish square blocks are not hand picked. I did not make that work in the level editor. I got inspired by theSmart Tile Objects tutorial by HeartBeast.

I simply downloaded the Square Blocks Textures from OpenGameArt.org, cut and pasted a bit in Paint.net to adapt it to my needs, and imported the end result as a background in GameMaker Studio.

My background version. Easier to import in GameMaker Studio.
My background version. Easier to import in GameMaker Studio.

I made so that when the floor/wall object (which is invisible) is created, it places random tile from that background into the level. Covering the same surface the object covers.

This makes for a pretty quick and dirty way to test a level with low object counts. Also gives me a little “randomized” effect every time I start the level.

What if I don’t want all that randomness? It’s as easy as usingrandom_set_seed(1)

So, here is the create event of the smart object.

tile_fill_object(id, bg_blocks_16, 16, false)

And here is the tile_fill_object script

///tile_fill_obj(obj, tile, tile_size, randomize_tiles)

var obj         = argument[0]
var tile        = argument[1]
var tile_size   = argument[2]
var rand        = argument[3]

var col_num = background_get_width(tile) div tile_size
var row_num = background_get_height(tile) div tile_size

if !rand
{
    random_set_seed(1)
}

for (var row = 0; row < obj.image_yscale; row += 1)
{
    for (var col = 0; col < obj.image_xscale; col += 1)
    {
        var col_index = irandom(col_num - 1)
        var row_index = irandom(row_num - 1)

        tile_add(tile, col_index * tile_size, row_index * tile_size, tile_size, tile_size, obj.x + (col * tile_size), obj.y + (row * tile_size), obj.depth)
    }
}

This is how it looks in the level editor

Level editor with few big Smart Tile Objects
Level editor with few big Smart Tile Objects

While this is how it looks in the game

Same few big smart tiles objects. But with randomized tiles.
Same few big smart tiles objects. But with randomized tiles.