[Platformer Tutorial] Tile-Based Collisions At Smooth Subpixel Movement Speeds

yarrow

Member
GM Version: 2.3+
Target Platform: ALL
Download: N/A
Links: N/A

Summary:
This tutorial will explain how to check collisions and move at whole number values while calculating movement at subpixel values, creating the illusion of fluid, subpixel movement without messing up collisions. It is intended for use in a low resolution 2D platformer, will only deal with horizontal movement, and assumes you already have a tile-based collision system in place.

Tutorial:
I had a heckuva time trying to figure out how to couple subpixel movement speeds with tile-based collisions and couldn't find a tutorial that directly addressed my problem, so I'm writing one myself! I'm fairly new to GameMaker and there may be other/better ways to do this, but this works (for me) and I wanted to share in case anyone else runs into the same problem. This system simulates what was going on under the hood in NES-era games, where onscreen movement occurred at pixel units but the subpixel position was stored and used for calculations.

Issue: I had a robust collision system in place (based on code from @Slyddar 's fantastic 2D platformer tutorial) which checks collisions and moves at whole number values, but I wanted to move at subpixel speeds. The game I'm working on is low resolution (240 x 180 pixels) and my minimum walk speed and acceleration values are fractional (both under one-tenth of a pixel), so the horizontal movement under this system was choppy and, indeed, didn't even register onscreen until over a dozen frames, since the one-pixel movement minimum hadn't been reached yet.

Solution: Store the subpixel remainder of your movement speed in a "subpixel bank" before flooring your speed for the collision check and, once this bank reaches a whole number, add this extra pixel to your floored speed. If there is a collision, snap and set all of your horizontal speed variables to zero. If there is not a collision, move an extra pixel, then restore your horizontal speed to its pre-collision check value. This grants you subpixel precision for your animation speeds, movement calculations and state machine checks but integer-only collisions/movement.

That's the gist of it anyway! I'll break it down in detail below. All of the code below comes from my collision script, with the exception of a couple of helper scripts (I'll point those out). Anything that's not a local variable has either been declared in the player object's create event or in my macros script. I want thank @Slyddar for coming up with the core source code; much of what's below has been copied, studied, adopted and tweaked from his excellent Castle Raider tutorial.

***

NOTE: My code includes a few bonus features that may or may not be relevant to you. My player sprite is twice the height of my tile size, so I added an additional collision check. I also wanted my player to be able to jump above room height, so that required an additional collision check as well. If these are relevant to you, great! If not, you may be able to simplify the code.

Also, here is the order of operations in my player object's step event: Get Input > Calculate Movement > Determine Next State > Check Collisions/Move > Animate Sprite > Change State

***

We'll start out by initializing a couple of variables:

GML:
function collisionCheck(){
    // Horizontal Collision
    var hMoveDir = sign(hSpeed);
    var side = noone;

    // Determine Which Side To Test
    if (hMoveDir > 0) side = bbox_right;
    else if (hMoveDir < 0) side = bbox_left;

Pretty straightforward but hMoveDir will be either -1 (hSpeed < 0, moving left), 1 (hSpeed > 0, moving right) or 0 (no hSpeed), so we will check collisions in the direction we're moving or, if not moving, not check collisions at all.

Next, if side is not equal to noone, we know that we are moving, so let's check collisions.

GML:
    // Check Collisions If hSpeed != 0
    if side != noone {
        var hSpeedCheck;
        var xOriginOffset = side - x;
        var maskHeightHalf = (bbox_bottom + bbox_top) * 0.5;
        var bboxBottomOffset = (sprite_height - 1) - sprite_get_bbox_bottom(sprite_index);

Let's initialize a few more variables. hSpeedCheck will be used during the collision check to look for a tile either 1 (if hSpeed < 1) or hSpeed (if hSpeed > 1) away from the side we're testing. This is because we still want to test for collisions even if our hSpeed is only fractional (since hSpeed will be floored before the collision test). This will be explained in greater detail later on.

xOriginOffset gives us the difference between the side we're testing and our player's x position. maskHeightHalf gives us the middle position of our sprite's collision mask, which is useful if your sprite is twice the height of your tiles. bboxBottomOffest gives us difference between bbox_bottom and the bottom of your sprite, which is useful if your player sprite's origin is higher than the bottom of your sprite.

Next, we will store our fractional hSpeed and floor our hSpeed to a whole number before we check collisions.

GML:
        // Floor Speed & Store Decimals
        hSpeedDecimal = hSpeed - (floor(abs(hSpeed)) * hMoveDir);
        hSpeed -= hSpeedDecimal;
  
        // Calculate Horizontal Subpixel Position
        hSubpixels = hSpeedDecimal;
        hSubpixels -= abs(hSpeedDecimal mod SUBPIXEL_SIZE) * hMoveDir;
        hSubpixelBank += hSubpixels;
        if abs(hSubpixelBank) >= 1 { 
            hExtraPixel = true;
            hSubpixelBank -= floor(abs(hSubpixelBank)) * hMoveDir;
        }
  
        // Add Extra Pixel To Horizontal Speed
        if (hExtraPixel) hSpeed += 1 * hMoveDir;

First, we find the floor of the absolute value of hSpeed, multiply it by 1 or -1 to give it the correct sign, then subtract it from our hSpeed, which will give us the fractional amount and store it in a new variable called hSpeedDecimal. Then, having stored it, we subtract that fractional amount from our hSpeed so that it is now a whole number.

Next we set up our system for incrementing our player's subpixel movement. First, we set our hSubpixels variable equal to the amount of hSpeedDecimal, then we floor it to a subpixel value by taking that fractional amount, modding it by the value of a subpixel (SUBPIXEL_SIZE, set to .0625), and subtracting that new sub-subpixel remainder from our hSubpixels variable. (The reason I do this is because I don't want the sub-subpixels to factor into my actual movement, but I do want to use them for my speed calculations during the subsequent step.)

Having done this, we can now add this subpixel value to the hSubpixelBank variable. When the amount in the bank is greater than or equal to one, a flag variable called hExtraPixel is set to true, and we subtract a pixel from the bank. Later, if our hExtraPixel flag returns true, we add an extra pixel to our current floored hSpeed. We're almost ready to check for collisions.

GML:
        // Check Minimum Of hSpeed (If Integer Or Greater) Or One (If Decimal) From Side
        if (side = bbox_right) hSpeedCheck = max(1, hSpeed);
        else if (side = bbox_left) hSpeedCheck = min(-1, hSpeed);

        // Test Top & Bottom (& Middle (& Sprite Bottom)) Of Side
        var t1 = tilemap_get_at_pixel(global.tilemap, side + hSpeedCheck, bbox_top);
        var t2 = tilemap_get_at_pixel(global.tilemap, side + hSpeedCheck, bbox_bottom);
        var t3 = tilemap_get_at_pixel(global.tilemap, side + hSpeedCheck, maskHeightHalf);
        var t4 = tilemap_get_at_pixel(global.tilemap, side + hSpeedCheck, bbox_bottom + bboxBottomOffset + 1);

But first, we need to set our hSpeedCheck variable. If we're checking bbox_right, we want to check the greater of either 1 or our hSpeed; if bbox_left, we'll check the minimum of -1 or hSpeed, if it's lower. As mentioned earlier, this will allow us to check for collisions even if our hSpeed is zero at this point (since fractions have been floored). If our hSpeed is 1 or more (we started the check at a speed of one or greater), we'll snap to a tile once a collision is found, and then reset all of our hSpeed variables. However, even if our hSpeed is less than one (we started the check at a subpixel amount), we still want to look for a collision one pixel away. If we find one, we won't need to snap to a tile but we will want to reset our hSpeed variables.

So we're looking for a tile either 1 or hSpeed away from whatever side we're testing. (As a side note, var t1 and var t2 might be the only two tests you need; in my case, var t3 is used to test the midpoint of my taller-than-a-tile player sprite, and var t4 is used to test for a collision one pixel below the bottom of my sprite if my player has risen completely above room height.)

Now we're ready to perform our collision check:

GML:
        // Collision Found
        if    ((t1 != VOID) and (t1 != SEMISOLID) and t1 != UND) or
            ((t2 != VOID) and (t2 != SEMISOLID) and t2 != UND) or
            ((t3 != VOID) and (t3 != SEMISOLID) and t3 != UND) or
            ((t1 == UND) and (t2 == UND) and (t3 == UND) and (t4 == SOLID)) {
            if hSpeed > 0 {
                // Heading Right; Move Flush Against Collision Tile's bbox_left
                var t1x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_top);
                var t2x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_bottom);
                var t3x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, maskHeightHalf);
                var t4x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_bottom + bboxBottomOffset + 1);
                // Snap To Smallest (Leftmost) x Position Times Tile Size Minus 1
                var tileBboxLeft = minPos(t1x, t2x, t3x, t4x) * TILE_SIZE;
                x = tileBboxLeft - 1 - xOriginOffset;
            } else if hSpeed < 0 {
                // Heading Left; Move Flush Against Collision Tile's bbox_right
                var t1x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_top);
                var t2x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_bottom);
                var t3x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, maskHeightHalf);
                var t4x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_bottom + bboxBottomOffset + 1);
                // Snap To Largest (Rightmost) x Position Plus 1 Times Tile Size
                var tileBboxRight = (max(t1x, t2x, t3x, t4x) + 1) * TILE_SIZE;   
                x = tileBboxRight - xOriginOffset;
            }
            // Reset hSpeed Variables (Even If Fractional hSpeed)
            hSpeedResets();
        }

Basically, we're looking for a solid tile at t1, t2 or t3, OR a solid tile at t4 if the player sprite is above the room height (in which case t1, t2 and t3 would return 'undefined' tiles). If any of these checks is true, we would be moving into a collision, so let's say we're moving right: we take the smallest (leftmost, or closest to us) tile cell x position from the checks we've performed, multiply it by our tile size to get the actual x position of the tile's side, then snap one pixel to the left of it (also compensating for the difference between our bbox_right and x position).

Note that I'm using a custom script, minPos( ), and not min( ), to find the smallest x value. This script returns the smallest positive number of three values, or a fourth value if all three are negative. I'm using this because of my above-room check; you can just use min( ) if you're not checking a tile that could return a negative value. Otherwise, here is my minPos( ) script:

GML:
/// @arg value1
/// @arg value2
/// @arg value3
/// @arg value4
function minPos(value1, value2, value3, value4){
    // Returns smallest positive number of three values, returns value4 if all negative
    if (value1 >=0) and (value2 >= 0) and (value3 >= 0) return min(value1, value2, value3);
    else if (value1 >= 0) and (value2 >= 0) and (value3 < 0) return min(value1, value2);
    else if (value1 >= 0) and (value2 < 0) and (value3 >= 0) return min(value1, value3);
    else if (value1 < 0) and (value2 >= 0) and (value3 >= 0) return min(value2, value3);
    else if (value1 >= 0) and (value2 < 0) and (value3 < 0) return value1
    else if (value1 < 0) and (value2 >= 0) and (value3 < 0) return value2
    else if (value1 < 0) and (value2 < 0) and (value3 >= 0) return value3
    else return value4;
}

Our leftward collision is almost the same as our rightward collision except that, because of the way tilemap_get_cell_x_at_pixel works, we want to add one to the maximum (rightmost, closest to us) tile cell x position and multiply it by our tile size, which will give us the x position of the left edge of the tile we're currently occupying (which, incidentally, is what we need to snap to). Then we adjust for the difference between our x position and bbox_left and we're all set.

Now, since we've collided, we call a custom script named hSpeedResets( ), which sets hSpeed, hSpeedDecimal and hSubpixelBank to 0, and hExtraPixel to false. If our hSpeed was fractional before the collision check, we will not have snapped to a tile (since we were not technically moving) but we will still be able to reset our hSpeed variables because we've technically collided.

This next part is optional but I wanted my game to allow the player to walk to the edge of the screen and, even if there were no solid tiles to stop them, be prevented from moving offscreen (therefore treating it as a collision). Here is the code that will accomplish this:

GML:
else {
            // Clamp Leftward Horizontal Movement To Room Dimensions
            if (side == bbox_left) and (bbox_left + min(-1, hSpeed) < 0) {
                if (hSpeed < 0) x -= bbox_left;
                hSpeedResets();
            }
            // Clamp Rightward Horizontal Movement To Room Dimensions
            else if (side == bbox_right) and (bbox_right + max(1, hSpeed) >= room_width) {
                if (hSpeed > 0) x = room_width - 1 - (bbox_right - x);
                hSpeedResets();
            }
        }

This functions much the same as our regular collision checks; we check the higher absolute value of either one or hSpeed, and if we're moving past the edge of the screen, either snap one tile away from the edge and reset speed (if current hSpeed is not 0) or just reset speed (if original hSpeed was fractional).

Now that we've finished checking for collisions, if our hSpeed has remained intact (no collisions), we will move to a new x position and restore our hSpeed to its pre-collision check value.

GML:
        // Move If No Collisions Found
        x += hSpeed;
      
        // Reapply Carried-Over Decimals To (& Subtract Extra Pixel From) Post-Movement hSpeed
        hSpeed += hSpeedDecimal;
        if hExtraPixel {
            hSpeed -= 1 * hMoveDir;
            hExtraPixel = false;
        }
    }

Once we've moved, the extra pixel (which simply represented the stored fractional amounts from previous collision checks) needs to be subtracted from our hSpeed, and we also set our hExtraPixel variable to false (since we've used up the extra pixel). Now we can go into our animation and movement calculation checks with our original, subpixel-precision hSpeed, and store a new amount in the pixel bank during our next collision check.

Hope this helps someone! I'm not super fluent in GML so may not be able to help with other peoples' code problems but am more than happy to explain, clarify or elaborate upon anything in the above code. I'm admittedly new to programming so if there's a better way to do any of this, your input will be most welcome. Here's the complete code in sequence:

GML:
function collisionCheck(){
    // Horizontal Collision
    var hMoveDir = sign(hSpeed);
    var side = noone;

    // Determine Which Side To Test
    if (hMoveDir > 0) side = bbox_right;
    else if (hMoveDir < 0) side = bbox_left;

    // Check Collisions If hSpeed != 0
    if side != noone {
        var hSpeedCheck;
        var xOriginOffset = side - x;
        var maskHeightHalf = (bbox_bottom + bbox_top) * 0.5;
        var bboxBottomOffset = (sprite_height - 1) - sprite_get_bbox_bottom(sprite_index);
      
        // Floor Speed & Store Decimals
        hSpeedDecimal = hSpeed - (floor(abs(hSpeed)) * hMoveDir);
        hSpeed -= hSpeedDecimal;
  
        // Calculate Horizontal Subpixel Position
        hSubpixels = hSpeedDecimal;
        hSubpixels -= abs(hSubpixels mod SUBPIXEL_SIZE) * hMoveDir;
        hSubpixelBank += hSubpixels;
        if abs(hSubpixelBank) >= 1 { 
            hExtraPixel = true;
            hSubpixelBank -= floor(abs(hSubpixelBank)) * hMoveDir;
        }
  
        // Add Extra Pixel To Horizontal Speed
        if (hExtraPixel) hSpeed += 1 * hMoveDir;
      
        // Check Minimum Of hSpeed (If Integer Or Greater) Or One (If Decimal) From Side
        if (side = bbox_right) hSpeedCheck = max(1, hSpeed);
        else if (side = bbox_left) hSpeedCheck = min(-1, hSpeed);

        // Test Top & Bottom (& Middle (& Sprite Bottom)) Of Side
        var t1 = tilemap_get_at_pixel(global.tilemap, side + hSpeedCheck, bbox_top);
        var t2 = tilemap_get_at_pixel(global.tilemap, side + hSpeedCheck, bbox_bottom);
        var t3 = tilemap_get_at_pixel(global.tilemap, side + hSpeedCheck, maskHeightHalf);
        var t4 = tilemap_get_at_pixel(global.tilemap, side + hSpeedCheck, bbox_bottom + bboxBottomOffset + 1);

        // Collision Found
        if    ((t1 != VOID) and (t1 != SEMISOLID) and t1 != UND) or
            ((t2 != VOID) and (t2 != SEMISOLID) and t2 != UND) or
            ((t3 != VOID) and (t3 != SEMISOLID) and t3 != UND) or
            ((t1 == UND) and (t2 == UND) and (t3 == UND) and (t4 == SOLID)) {
            if hSpeed > 0 {
                // Heading Right; Move Flush Against Collision Tile's bbox_left
                var t1x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_top);
                var t2x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_bottom);
                var t3x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, maskHeightHalf);
                var t4x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_bottom + bboxBottomOffset + 1);
                // Snap To Smallest (Leftmost) x Position Times Tile Size Minus 1
                var tileBboxLeft = minPos(t1x, t2x, t3x, t4x) * TILE_SIZE;
                x = tileBboxLeft - 1 - xOriginOffset;
            } else if hSpeed < 0 {
                // Heading Left; Move Flush Against Collision Tile's bbox_right
                var t1x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_top);
                var t2x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_bottom);
                var t3x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, maskHeightHalf);
                var t4x = tilemap_get_cell_x_at_pixel(global.tilemap, side + hSpeed, bbox_bottom + bboxBottomOffset + 1);
                // Snap To Largest (Rightmost) x Position Plus 1 Times Tile Size
                var tileBboxRight = (max(t1x, t2x, t3x, t4x) + 1) * TILE_SIZE;   
                x = tileBboxRight - xOriginOffset;
            }
            // Reset hSpeed Variables (Even If Fractional hSpeed)
            hSpeedResets();
            collided = true;
        } else {
            // Clamp Leftward Horizontal Movement To Room Dimensions
            if (side == bbox_left) and (bbox_left + min(-1, hSpeed) < 0) {
                if (hSpeed < 0) x -= bbox_left;
                hSpeedResets();
                collided = true;
            }
            // Clamp Rightward Horizontal Movement To Room Dimensions
            else if (side == bbox_right) and (bbox_right + max(1, hSpeed) >= room_width) {
                if (hSpeed > 0) x = room_width - 1 - (bbox_right - x);
                hSpeedResets();
                collided = true;
            }
        }
      
        // Move If No Collisions Found
        x += hSpeed;
      
        // Reapply Carried-Over Decimals To (& Subtract Extra Pixel From) Post-Movement hSpeed
        hSpeed += hSpeedDecimal;
        if hExtraPixel {
            hSpeed -= 1 * hMoveDir;
            hExtraPixel = false;
        }
    }
}
 
Last edited:
Top