Player Movement Functions

Player movement in this game is complicated. The usual two-dimensional side-scrolling controls are present – walk left/right, jump, fall, and the player can usually look up and down to scroll the screen some distance. Some map tiles permit passage if they are jumped through, but prevent movement if something falls onto them. Some surfaces are sloped at a 45° angle, and other surfaces can move and carry the player along with them. The player may find a Scooter that allows them to fly freely to any area of the map.

On top of all that, the player has an unusual movement ability in that they can cling to certain vertical walls by pushing into them while jumping or falling, and repeatedly jump and re-cling to climb great distances. Various surfaces in the game are slippery and work against efforts to cling or walk uphill.

The player’s primary defense mechanism is movement-based, and involves falling onto the top of an enemy actor (an action called pouncing). Actors that have been pounced cause the player to recoil some distance back up into the air. Other actors may push the player around the map with a force that cannot be counteracted by user input.

Ground Rules

The regular walk speed of the player is one tile per game tick. The rise and fall speeds may be either one or two tiles per game tick, depending on how much “momentum” the move has. The player moves one tile in both horizontal and vertical directions while walking over sloped tiles, making the effective speed over those areas √2 tiles per game tick. Look up/down moves one tile per game tick, while view centering typically moves at a speed that matches what the player is doing.

The player may enter any empty space of the map provided the leading edge of all the player sprite tiles move into clear space. The player may also move into areas occupied by map tiles provided the tile attributes specify that all the involved tiles are passable in that direction.

Begging Permission and Asking Forgiveness

There are two movement paradigms that can be used to determine if a proposed player move is legal. Code can ask for permission (or look before leaping):

/* Check if the player can move one tile right, and if so, move there. */
if (TestPlayerMove(DIR4_EAST, playerX + 1, playerY) == MOVE_FREE) {
    playerX++;
}

Or it can beg forgiveness:

/* Move the player right. If they moved into a bad spot, revert the move. */
playerX++;
if (TestPlayerMove(DIR4_EAST, playerX, playerY) != MOVE_FREE) {
    playerX--;
}

Both are acceptable forms that produce the correct answer. One’s a little less dense, one’s a little more mild-mannered in terms of keeping the global state undisturbed. This game uses both approaches interchangeably, and it’s important to recognize both patterns and understand what the code is trying to do: The player is at some X position, they want to move one tile toward DIR4_EAST to X + 1, and the legality of this move must be decided before the move is realized.

TestPlayerMove()

The TestPlayerMove() function tests if the player sprite is permitted to move in the direction specified by dir and enter the map tiles around x_origin and y_origin. Depending on the result of the test, one of the MOVE_* constants is returned according to the following table:

Return ValueDescription
MOVE_FREEThe move is permitted; none of the map tiles the player touches in the new location interfere with movement in the specified direction.
MOVE_BLOCKEDThe move is forbidden; at least one of the map tiles the player touches in the new location forbids movement in the specified direction.
MOVE_SLOPEDThe move is permitted as with MOVE_FREE, however at least one tile at the player’s feet is sloped and a subsequent vertical adjustment will be required to keep the player at the correct height.

This function always clears the global isPlayerSlidingEast and isPlayerSlidingWest flags, and the only time they could be re-enabled is when a dir of DIR4_SOUTH is passed. pounceStreak may also be zeroed in this direction.

canPlayerCling is always recalculated whenever dir is DIR4_WEST or DIR4_EAST.

word TestPlayerMove(word dir, word x_origin, word y_origin)
{
    word *mapcell;
    word i;

    isPlayerSlidingEast = false;
    isPlayerSlidingWest = false;

Every call to this function clears both the isPlayerSlidingEast and isPlayerSlidingWest flags (although not every path through the function is capable of re-enabling either of them).

    switch (dir) {

The entirety of this function is arranged as a switch statement, with each case label handling one of the four possible DIR4_* values.

    case DIR4_NORTH:
        if (playerY - 3 == 0 || playerY - 2 == 0) return MOVE_BLOCKED;

This case handles moves in the DIR4_NORTH direction, for situations where the player sprite is rising vertically on the screen.

playerY represents the vertical position of the bottom row of player sprite tiles, or colloquially, the player’s feet. The player sprite is always five tiles tall no matter which sprite frame is being shown. playerY - 3 is the second row of tiles from the top, while playerY - 3 is the row of tiles at the center of the sprite. If either of these expressions evaluates to zero, the player is partially off the top edge of the map already and there is absolutely no reason that they should be permitted to move any higher. MOVE_BLOCKED is returned in this case.

Note:

The offsets chosen here will allow the topmost row of player sprite tiles to leave the map. This allows their hair (but not the face) to leave view.

The bottom two rows are not tested here, due to the assumption that the player never moves vertically more than two tiles per game tick. Assuming everything is working properly, the upper half of the sprite will get caught before the bottom rows ever need to be considered.

        mapcell = MAP_CELL_ADDR(x_origin, y_origin - 4);

        for (i = 0; i < 3; i++) {
            if (TILE_BLOCK_NORTH(*(mapcell + i))) return MOVE_BLOCKED;
        }

        break;

The MAP_CELL_ADDR() returns a pointer to the word of mapData that represents the horizontal position in x_origin and the vertical in y_origin minus four. Since y_origin represents the tile containing the player’s feet, we need to move up four rows to locate the tile containing the top of their head. The mapcell pointer gets the address of the map tile containing the top-left tile of the player’s sprite.

The for loop iterates i from zero to two, each iteration representing an increasing horizontal position across the top of the sprite. mapcell + i is the address of that tile, and the dereferenced value is passed through TILE_BLOCK_NORTH() to determine if the tile in this location blocks northward movement. If any tile does, MOVE_BLOCKED is immediately returned without considering any additional tile positions.

If all three tiles at the top of the player’s sprite permitted the move, break jumps to the end of the function, where MOVE_FREE is ultimately returned.

    case DIR4_SOUTH:
        if (maxScrollY + SCROLLH == playerY) return MOVE_FREE;

        mapcell = MAP_CELL_ADDR(x_origin, y_origin);

This is the case for the DIR4_SOUTH direction.

The sum of maxScrollY plus SCROLLH represents the first tile past the bottom of the visible map. There is actually a hidden row of map tiles at this elevation, but it is garbage and shouldn’t be considered as something that could interact with the player. (See the map format page for details.) In the case where the playerY is passing through this garbage row, MOVE_FREE is unconditionally returned to prevent anything from interfering randomly.

The mapcell pointer is set up as in the DIR4_NORTH case, except here there are no corrections done to y_origin since we actually want to look at the tiles by the player’s feet here.

        if (
            !TILE_BLOCK_SOUTH(*mapcell) &&
            TILE_SLOPED(*mapcell) &&
            TILE_SLIPPERY(*mapcell)
        ) isPlayerSlidingEast = true;

        if (
            !TILE_BLOCK_SOUTH(*(mapcell + 2)) &&
            TILE_SLOPED(*(mapcell + 2)) &&
            TILE_SLIPPERY(*(mapcell + 2))
        ) isPlayerSlidingWest = true;

Here we are testing for tiles that are both sloped and slippery. The game needs to know about this condition to force-slide the player down the icy hills.

In order for the player to slide east, the tile value at the player sprite’s bottom left corner (*mapcell) needs to not block southward movement (!TILE_BLOCK_SOUTH()), it must be sloped (TILE_SLOPED()) and it must be slippery (TILE_SLIPPERY()). If all three of these conditions match, isPlayerSlidingEast is set to true.

isPlayerSlidingWest is set exactly the same way, except the tile being tested is at the player sprite’s bottom right corner, *(mapcell + 2).

        for (i = 0; i < 3; i++) {
            if (TILE_SLOPED(*(mapcell + i))) {
                pounceStreak = 0;
                return MOVE_SLOPED;
            }

            if (TILE_BLOCK_SOUTH(*(mapcell + i))) {
                pounceStreak = 0;
                return MOVE_BLOCKED;
            }
        }

        break;

As with the DIR4_NORTH case, the bottom edge of the player’s sprite is tested against each map tile in a three-iteration loop. The tests start at the player’s bottom left tile (mapcell) and move right as i increases.

If any of the tiles the player is entering are sloped (TILE_SLOPED()) the player is considered to be standing on solid ground, which clears the running pounceStreak counter. MOVE_SLOPED is returned in this instance.

Otherwise if the tile was not sloped, but was solid in this direction (TILE_BLOCK_SOUTH()), the player is also considered to be on solid ground and pounceStreak is cleared. MOVE_BLOCKED is returned to indicate the prohibited move.

If all three tiles at the bottom of the player’s sprite permitted the move, break jumps to the end of the function, where MOVE_FREE is ultimately returned.

    case DIR4_WEST:
        mapcell = MAP_CELL_ADDR(x_origin, y_origin);
        canPlayerCling = TILE_CAN_CLING(*(mapcell - (mapWidth * 2)));

This is the case for the DIR4_WEST direction.

The mapcell pointer is set up just like it was in the DIR4_SOUTH case, referring to the tile at the bottom left of the player’s sprite position.

mapcell - (mapWidth * 2) is a bit of linear addressing. The map is conceptually a two-dimensional array with a horizontal size of mapWidth and a not-relevant-right-now height. In actuality it’s a one-dimensional array of tile elements in row-major order. Each single-element step represents a move in the horizontal direction, while a step of mapWidth represents a vertical move to the same column in an adjacent row. Subtracting mapWidth * 2 from any tile position selects the location two tiles above. The value being dereferenced here is the map tile that is two rows above mapcell, which is the left tile in the middle row of the player’s sprite, approximately where the suction cup hands are depicted on a west-facing player sprite.

TILE_CAN_CLING() determines if the tile at the player sprite’s hand is clingable, and the result is stored in canPlayerCling.

        for (i = 0; i < 5; i++) {
            if (TILE_BLOCK_WEST(*mapcell)) return MOVE_BLOCKED;

            if (
                i == 0 &&
                TILE_SLOPED(*mapcell) &&
                !TILE_BLOCK_WEST(*(mapcell - mapWidth))
            ) return MOVE_SLOPED;

            mapcell -= mapWidth;
        }

        break;

The main test is structured as a five-iteration for loop, each iteration testing the next higher tile at the left edge of the player’s sprite. If any tile blocks the move (TILE_BLOCK_WEST()), MOVE_BLOCKED is returned.

On the first iteration (only), if the examined tile is sloped (TILE_SLOPED()) and the tile directly above it permits movement in our current direction (!TILE_BLOCK_WEST()) the MOVE_SLOPED value is returned and testing stops. This condition means that the player is walking up a hill and the eventual increase in elevation will not cause them to enter a tile that would refuse the move.

Otherwise mapcell is reduced by mapWidth, selecting the next higher tile, and the loop keeps going. If the loop runs to completion without finding any blocking tiles, break jumps to the end of the function, where MOVE_FREE is ultimately returned.

    case DIR4_EAST:
        mapcell = MAP_CELL_ADDR(x_origin + 2, y_origin);
        canPlayerCling = TILE_CAN_CLING(*(mapcell - (mapWidth * 2)));

        for (i = 0; i < 5; i++) {
            if (TILE_BLOCK_EAST(*mapcell)) return MOVE_BLOCKED;

            if (
                i == 0 &&
                TILE_SLOPED(*mapcell) &&
                !TILE_BLOCK_EAST(*(mapcell - mapWidth))
            ) return MOVE_SLOPED;

            mapcell -= mapWidth;
        }

        break;

This is the case for the DIR4_EAST direction. It’s identical to the DIR4_WEST case, except the initial mapcell position is targeting the bottom right tile of the player’s sprite at x_origin + 2. (The inner tests use TILE_BLOCK_EAST() as well.)

    }

    return MOVE_FREE;
}

In the fallback case, if nothing inside any of the earlier branches returned anything, MOVE_FREE is the return value.

Dancing on the Ceiling

This shouldn’t happen:

Screenshot of the E2M6 bottomless pit bug in action.

When playing E2M6, it does. This map exhibits a bug where the player can’t die in any of the bottomless pits, and is able to walk and jump in those areas as if they were solid ground. To get to the bottom of this behavior it’s necessary to understand exactly what happens when TestPlayerMove() makes its decisions. When called with DIR4_SOUTH as its first argument, this is approximately the C code that runs:

if (maxScrollY + SCROLLH == playerY) return MOVE_FREE;

mapcell = MAP_CELL_ADDR(x_origin, y_origin);

for (i = 0; i < 3; i++) {
    if (TILE_BLOCK_SOUTH(*(mapcell + i))) {
        pounceStreak = 0;
        return MOVE_BLOCKED;
    }
}

return MOVE_FREE;

First a check is made to see if the player’s feet entered into the row of garbage tiles that always occupy the bottom row of each map. The map format page has more information, but the long and short is that the bottom row of tiles (stored at the end of the map data) is incomplete at the right side and on most maps the whole row contains incomplete or nonsensical tile data. The game never allows this row to be scrolled into view, and if the player’s position is already inside this row (maxScrollY + SCROLLH) the move is unconditionally permitted (MOVE_FREE).

At every other Y position, whether in a pit or not, mapcell points to the map tile memory address at the x_origin and y_origin positions where the player wants to move. In a three-iteration for loop (one iteration per player sprite tile horizontally), each tile to the right of that original x_origin position gets tested, and if any tile blocks southward movement the MOVE_BLOCKED value is returned. If nothing blocks, MOVE_FREE is returned. This tests every map tile that the bottom of the player’s sprite could potentially touch.

Interestingly, the maxScrollY-involved check uses the current value of playerY and not the passed y_origin value that the rest of the movement calculations use. Typically TestPlayerMove() is passed a speculative location where the player wants to move, but this check uses the place where they already are. It calls into question how the player got into this row in the first place.

Over in MovePlayer the rough gist of player falling is:

if (isPlayerFalling) {
    playerY++;
    if (TestPlayerMove(DIR4_SOUTH, playerX, playerY) != MOVE_FREE) {
        isPlayerFalling = false;
        playerY--;
    }
}

This is heavily simplified, but the important bit is that the player moves down first, then the position where they ended up is checked, and if that tile didn’t permit the action the move gets reverted. Once the player moves into the garbage row, TestPlayerMove() always returns MOVE_FREE and the move here is never unwound.

So that explains what happens when the player touches the edge of the map, and how they are permitted to pass through unviewable garbage data, but it doesn’t really get us any closer to understanding the bug.

When the player falls an additional tile, things get interesting fast. The player’s Y position is now no longer in the final row of garbage tiles, it’s past it. On a map that is (e.g.) 64 tiles high, we’re now in the 65th tile row. The game doesn’t really test for this condition in the general sense, it just steamrolls ahead with the Y value, assuming it must point to some valid map data. This ultimately reads past the end of a buffer, which is undefined behavior in C, therefore the compiler (and the program) can do any damned thing it desires.

We also need to remember the memory model of the IBM PC, and specifically the way the Intel x86 processors address memory in real mode. Being 16-bit processors, the largest value that can be handled in a register or memory operand is 16 bits wide, in the range 0–65,535. In order to unambiguously refer to more than 64 KiB of memory, addresses are split into paragraph-sized segments and single-byte offsets. The segment values cover 1,024 KiB of memory with 16-byte granularity (that’s the size of one paragraph), and individual bytes can be accessed by adding an offset to that.

Generally C pointers acquired by Borland’s malloc() will set the segment address to the first paragraph containing the data and the offset gets a value between zero and 15 to point to the first byte exactly. Any pointer math is implemented by moving the offset address away from the fixed segment address. Remember though that the offset is still an unsigned 16-bit value under the hood, and if it should overflow it goes right back to the beginning of the same segment.

So what does mapcell = MAP_CELL_ADDR(x_origin, y_origin) do with an excessive y_origin? Un-macroing that, we get:

mapcell = mapData.w + (y_origin << mapYPower) + x_origin;

After a quick trip through the compiler and then a disassembler:

        mov   ax,[bp+10]         ; [function argument y_origin]
        mov   cl,[0x4b70]        ; [mapYPower]
        shl   ax,cl
        shl   ax,1
        les   bx,[0x4b6a]        ; [mapData]
        add   bx,ax              ; ES:BX = mapData + (y_origin << mapYPower)
        mov   ax,di              ; DI has our copy of function arg x_origin
        shl   ax,1
        add   bx,ax              ; ES:BX += x_origin
        mov   [bp-2],es
        mov   [bp-4],bx          ; [mapcell] = ES:BX

In our example’s 64 tile-high map, the width is 512 tiles by definition (the product must always equal 32,768). mapYPower is therefore 9. Lets say the player has continued to fall and is now interacting with tiles on (zero-indexed) row 65, way outside the legal Y range of 0–63. AX takes 65, CL is 9, and the first shl produces 33,280 in AX. What this is saying is that, to access tiles on row 65 of a map with these dimensions, we need to skip over 33,280 linear tiles of data to select that row.

Each tile is two bytes (owing to the pointer referring to a word type in the way it is used here) so we need to double 33,280 to get something we can use as a memory offset. The compiler implements that as a shl by one, which is a handy speed optimization.

That shl is where the cold realities of the processor whack us. We overflow the 16-bit register, wrap past zero, and AX becomes 1,024. We end up with the same result that we would’ve had if we had entered the function with y_origin set to 1. les bx,... sets ES to the segment address of mapData and BX to the offset to its first byte. The result in AX, right or wrong, increases the offset in BX and then x_origin increases it further, and nothing remembers whether or not anything overflowed.

In terms of map space, it’s a reasonable behavior. If you read a piece of paper from left to right, then top to bottom, eventually you’re going to find yourself at the bottom-right corner with nowhere else to go. The only logical place to jump is back to the top-left of something. It also makes perfect sense from the standpoint of the processor, because that conceptual piece of paper is mapped onto a 64 KiB range of offset values that wraps the exact same way.

When the player falls into a bottomless pit, most of the game’s logic has no trouble with the excessive Y position, but the map intersection calculations behave as if the player wrapped around and started falling into the top of the sky in row zero. And actually, most of the time this isn’t a problem – maps with bottomless pits tend to also have vast unbounded open sky in the top few map rows. But E2M6 is different: It evokes a sort of cave vibe, complete with a line of solid ground at the top of the visible scroll area to suggest that you’re in a system of worm tunnels underground.

Pictures say more than words:

Diagram of the “wrap-around” behavior that occurs when the player’s Y position leaves the map data.

And that’s the cause: A buffer over-read wraps around harmlessly on the specific hardware the game was written for, allowing the design of an unrelated area of the map to influence whether or not the bottomless pits function as intended.

SetPlayerPush()

The SetPlayerPush() function configures the global game state to cause the player to be pushed in a direction dir at speed tiles per game tick for a total duration of max_time game ticks. During this time, the player sprite will be drawn using force_frame as the frame number. If the abortable flag is set, the player can cancel the effect of the push by jumping. If the blockable flag is set, the push will consider and stop at any solid map tiles encountered along the way. Conventional actor pushes use the blockable flag. The pipe system uses a non-blockable push to move the player around.

Nothing in the unmodified game sets the abortable flag, and all pushes use the constant speed of 2.

void SetPlayerPush(
    word dir, word max_time, word speed, word force_frame, bool abortable,
    bool blockable
) {
    playerPushDir = dir;
    playerPushMaxTime = max_time;
    playerPushTime = 0;
    playerPushSpeed = speed;
    playerPushForceFrame = force_frame;
    isPlayerPushAbortable = abortable;
    isPlayerPushed = true;

    scooterMounted = 0;

    isPlayerPushBlockable = blockable;

    isPlayerRecoiling = false;
    playerRecoilLeft = 0;

    ClearPlayerDizzy();
}

The push state is represented by eight global variables:

  • playerPushDir (set to dir) records the DIR8_* direction the push moves the player in.
  • playerPushMaxTime (set to max_time) represents the maximum number of game ticks the push should be allowed to run before it auto-cancels.
  • playerPushTime (initialized to zero) tracks the number of game ticks that the player has been pushed for. This value will increment from zero up to the maximum in playerPushMaxTime.
  • playerPushSpeed (set to speed) records the number of tiles the player should move during each game tick.
  • playerPushForceFrame (set to force_frame) controls which player sprite frame is drawn during the course of the push. This is usually one of two available PLAYER_PUSHED frames for regular actor-involved pushes and PLAYER_HIDDEN when inside pipes.
  • isPlayerPushAbortable (set to abortable) tracks whether this push should be able to be canceled early by using the “jump” command.
  • isPlayerPushed (set to true) is the main flag that enables the per-tick pushing logic.
  • isPlayerPushBlockable (set to blockable) tracks whether this push should consider any movement-blocking walls it encounters.

Separately from the push-specific variables, this function zeroes the scooterMounted variable to knock the player off any scooter they may be riding on. isPlayerRecoiling and playerRecoilLeft are both cleared to cancel any upward jump/recoil energy the player has amassed, and ClearPlayerDizzy() removes any dizzy effects the player is dealing with.

ClearPlayerPush()

The ClearPlayerPush() function immediately cancels any push the player may be experiencing, and returns the game state back to regular player control.

void ClearPlayerPush(void)
{
    isPlayerPushed = false;
    playerPushDir = DIR8_NONE;
    playerPushMaxTime = 0;
    playerPushTime = 0;
    playerPushSpeed = 0;
    playerPushForceFrame = 0;

    isPlayerRecoiling = false;
    playerRecoilLeft = 0;

    isPlayerPushAbortable = false;

    isPlayerFalling = true;
    playerFallTime = 0;
}

The push-specific variables isPlayerPushed, playerPushDir, playerPushMaxTime, playerPushTime, playerPushSpeed, playerPushForceFrame, and isPlayerPushAbortable are all set to zero here, erasing any evidence that a previous push may have left behind. (The isPlayerPushBlockable is left alone, which may be a small but inconsequential oversight.)

In order to return the player back to regular control, this function sets isPlayerFalling to true, which causes later movement code in MovePlayer to check if the player needs to fall any distance to contact the ground. In a typical horizontal-only push, it will be apparent that the player is already on the ground and no further adjustment will be required. Otherwise gravity will pull them down as expected.

To ensure sanity of the global state, isPlayerRecoiling, playerRecoilLeft, and playerFallTime are all cleared before returning control to the regular movement code. This is cheap insurance to make sure there are no abrupt movements due to the variables holding stale values.

MovePlayerPush()

The MovePlayerPush() function handles one game tick of player push movement. isPlayerPushed must be true for this to do anything, otherwise it behaves as a no-op.

Push configuration and the associated global state is described in SetPlayerPush(). If the configured “speed” value is greater than one, this function will move the player multiple tiles in the configured direction before returning.

void MovePlayerPush(void)
{
    word i;
    bool blocked = false;

    if (!isPlayerPushed) return;

    if (cmdJump && isPlayerPushAbortable) {
        isPlayerPushed = false;
        return;
    }

If isPlayerPushed is false, there is not currently an active push and this function should do nothing. return early in this case.

Otherwise if cmdJump is true, the user is currently holding the jump key/button down. If isPlayerPushAbortable is also true, the active push was configured such that the player is permitted to “jump out” of an active push. In this case, isPlayerPushed is cleared to immediately cancel the push, and an early return is taken. The player regains control at this point.

Note: None of the pushes in the retail game are set up with an abortable push, so this branch is never taken. The fact that ClearPlayerPush() is not called here suggests that maybe this was an early idea whose implementation didn’t mature with the rest of the feature.

    for (i = 0; i < playerPushSpeed; i++) {

The “speed” of a push is implemented by repeating the actual movement code multiple times, one iteration per tile moved. All pushes in the retail game produce a playerPushSpeed of 2, so this for loop body always runs twice per call. (The i variable is not used for anything beyond maintaining loop state.)

        if (
            playerX + dir8X[playerPushDir] > 0 &&
            playerX + dir8X[playerPushDir] + 2 < mapWidth
        ) {
            playerX += dir8X[playerPushDir];
        }

The playerPushDir value is one of the DIR8_* constants, which can be decomposed using the dir8X[] array into a X change in the range -1 – 1. This value is added to playerX to move the player. This is done conditionally, requiring the result to be larger than zero (so, not off the left map edge). The right edge of the player’s sprite (which is the point two tiles right of playerX) must also be smaller than mapWidth to keep the player inside the right map edge.

        playerY += dir8Y[playerPushDir];

playerY is modified the same way (using dir8Y[] this time) but without the bounds-checking. Broadly, the game gets away with this because vertical pushes only occur when interacting with pipe actors that are in fixed locations placed by the map author. It’s essentially impossible for a pipe actor to knock the player off the top/bottom of the map, while it’s quite plausible that a wandering actor can follow the player to a location where a horizontal push would reach a map edge.

        if (
            scrollX + dir8X[playerPushDir] > 0 &&
            scrollX + dir8X[playerPushDir] < mapWidth - (SCROLLW - 1)
        ) {
            scrollX += dir8X[playerPushDir];
        }

        if (scrollY + dir8Y[playerPushDir] > 2) {
            scrollY += dir8Y[playerPushDir];
        }

Similar test-then-modify behavior is repeated for scrollX and scrollY for view centering purposes.

Vertical movement is limited here, but only at the top of the map. I suspect these conditions were added as problems were encountered, and none of the maps had any vertical-pushing actors near the bottom of the map to warrant adding a test for it.

        if (isPlayerPushBlockable && (
            TestPlayerMove(DIR4_WEST,  playerX, playerY) != MOVE_FREE ||
            TestPlayerMove(DIR4_EAST,  playerX, playerY) != MOVE_FREE ||
            TestPlayerMove(DIR4_NORTH, playerX, playerY) != MOVE_FREE ||
            TestPlayerMove(DIR4_SOUTH, playerX, playerY) != MOVE_FREE
        )) {
            blocked = true;
            break;
        }
    }

If isPlayerPushBlockable is true, the current push needs to be aware of movement-blocking tiles that the player might hit. The player has already moved for this iteration, so it’s entirely possible that they’re already inside a location where they should not be. The current playerX and playerY values are passed to four TestPlayerMove() calls, each testing for a blocking tile attribute in the player’s current spot. If any of the four calls returns something other than MOVE_FREE – disregarding whether or not the player was moving in a prohibited direction at all – the player is now inside the wall. The blocked flag is set, and the outer for loop is terminated with break. In the case where the push moves the player multiple tiles per call, this will stop the loop at the first tile encountered even if there would otherwise have been more iterations to follow. This ensures that the ejection code below never needs to undo more than one tile of movement in total.

Otherwise, the for loop keeps moving the player until the playerPushSpeed limit is reached.

    if (blocked) {
        playerX -= dir8X[playerPushDir];
        playerY -= dir8Y[playerPushDir];
        scrollX -= dir8X[playerPushDir];
        scrollY -= dir8Y[playerPushDir];

        ClearPlayerPush();

If the previous loop ended because a wall was hit, blocked will be true and this code executes. This simply reverses the most recent addition performed on playerX, playerY, scrollX, and scrollY. This should eject the player to their last-known good location before TestPlayerMove() found a blocking tile.

Technically there are multiple edge-case bugs in here because we’re not exactly replicating the conditions that wrapped each of the original additions. In practice this would really only be noticeable during a diagonal move where the player hit a screen edge along one axis before hitting a solid tile perpendicular to that.

After unwinding the move, there is no reason to continue trying to move in the original direction, so the push ends with a call to ClearPlayerPush().

    } else {
        playerPushTime++;
        if (playerPushTime >= playerPushMaxTime) {
            ClearPlayerPush();
        }
    }
}

In the more common case, the for loop ended naturally without blocked getting set. The player is moving freely and they haven’t touched anything that should affect the push.

playerPushTime increments once for this game tick (regardless of how many tiles the player actually moved). Once the playerPushMaxTime is reached, the push ends naturally with ClearPlayerPush().

TryPounce()

The TryPounce() function checks if all necessary player variables are in the correct state to perform a pounce on an enemy actor. If this state is correct, a caller-provided recoil amount is imparted to the player and true is returned.

This function does not care about the X/Y position of the player or any actor – this function is only concerned with the variables that control things like player rise and fall. The caller is responsible for testing the relative positions of the player and an actor, setting the pass-by-global isPounceReady according to that result.

bool TryPounce(int recoil)
{
    static word lastrecoil;

    if (playerDeadTime != 0 || playerDizzyLeft != 0) return false;

This function maintains a local copy of the last recoil successfully imparted, and stores it in lastrecoil. This is occasionally needed by subsequent code.

If the player is either dead (a nonzero playerDeadTime) or dizzy (a nonzero playerDizzyLeft), they are incapable of performing a pounce even if all the other aspects of their movement would have allowed it. An early return of false prevents this pounce from producing a recoil, and informs the caller that this pounce was unsuccessful.

    if ((
        !isPlayerRecoiling || (isPlayerRecoiling && playerRecoilLeft < 2)
    ) && (
        (isPlayerFalling && playerFallTime >= 0) || playerJumpTime > 6
    ) && isPounceReady) {

Hoo boy.

If the player were already recoiling from a pounce, they would be incapable of pouncing another actor – therefore isPlayerRecoiling should be false for pouncing to proceed. In the opposite case (needlessly testing that isPlayerRecoiling is true when we already know it must be) a small exception is made for cases where playerRecoilLeft is less than two. The latter case covers the situation where the player has a nonzero playerRecoilLeft but is no longer rising – they are at the top of the parabola between recoiling and falling. In this case it should be permissible to pounce an actor, even though technically they are not done with the previous recoil.1

Separately, the player must either be falling (isPlayerFalling) or rising to near the maximum height they can reach by holding the jump key (playerJumpTime > 6). The test for a non-negative playerFallTime is always true and likely a holdover from an older implementation.

Finally, isPounceReady must also be true. The caller sets this based on whether the player is properly positioned above some actor.

        lastrecoil = playerRecoilLeft = recoil + 1;
        isPlayerRecoiling = true;

        ClearPlayerDizzy();

To cause the player to recoil, all that’s necessary is to write a value to playerRecoilLeft and set the isPlayerRecoiling flag to true. Here the value is recoil + 1.

Why? Why not.

There doesn’t seem to be a systemic reason why the recoil value needs to be increased by one; the game works correctly without the addition. It’s likely that somebody thought that the recoils all needed a bit more oomph, and it was easier to do that here rather than go through and adjust the arguments in all the TryPounce() calls.

The most recent value written to playerRecoilLeft is also stored in the local lastrecoil variable for possible later use. This variable is declared static and retains its value across calls.

ClearPlayerDizzy() has no immediate effect here – any active dizziness would involve a nonzero playerDizzyLeft which would prevent this if body from running in the first place. This does, however, cancel any queued dizziness that might be stored. This means that a player can fall an extreme distance that would normally incur a dizzy immobilization, but land on a pounceable actor before hitting the ground and suffer no dizziness at all.

        if (recoil > 18) {
            isPlayerLongJumping = true;
        } else {
            isPlayerLongJumping = false;
        }

        pounceHintState = POUNCE_HINT_SEEN;

If the player is recoiling a long distance (here where recoil is greater than 18, but this is not always consistent in other parts of the code) the isPlayerLongJumping flag is set to select an alternate player recoil sprite. This variable has no other effect on gameplay.

The player has clearly demonstrated their knowledge of the pouncing mechanic, so there is no reason to show them the pounce hint dialog anymore. Setting pounceHintState to POUNCE_HINT_SEEN has the same effect as the player having seen and dismissed the dialog.

        if (recoil == 7) {
            pounceStreak++;
            if (pounceStreak == 10) {
                pounceStreak = 0;
                NewActor(ACT_SPEECH_WOW_50K, playerX - 1, playerY - 5);
            }
        } else {
            pounceStreak = 0;
        }

        return true;

If the passed recoil is exactly seven, the pounced actor contributes to the “pounce streak” bonus. This is a 50,000 point award that is bestowed anytime the player pounces ten times without touching the ground. Only a handful of actors are set up to impart a recoil equal to seven:

  • Hopping Cabbage Creature (takes one pounce)
  • Ghost
  • Floating Moon
  • Jumping Baby Ghost
  • Flying Roamer Slug
  • Baby Ghost Egg (cracks when pounced or player is near)
  • Baby Ghost Egg (cracks when pounced)
  • Blue Ball/Parachute Creature
  • Boss
  • Suction Feet Creature
  • Spitting Turret Creature
  • Red Chomper Creature
  • Pink Worm
  • Translucent Pusher Robot
  • Blue Bird
  • Hopping Cabbage Creature (takes two pounces)

If an actor with an appropriate recoil value is being pounced, pounceStreak is incremented and, upon reaching ten, the bonus is given. NewActor() is called to insert a “Speech Bubble: Wow! 50,000 points!” (ACT_SPEECH_WOW_50K) directly above the current playerX/playerY position, and this new actor gives the bonus indirectly. pounceStreak is reset to zero as this happens, and the bonus is ready to be earned again if the map’s actors permit.

In the else case, the non-matching recoil spoils the streak and resets the pounceStreak to zero.

Since the player has successfully entered a pounce and the recoil has been imparted, a return value of true is provided to the caller.

    } else if (
        lastrecoil - 2 < playerRecoilLeft &&
        isPounceReady && isPlayerRecoiling
    ) {

For this else if to be evaluated, something in the previous if’s tests must’ve failed. This can happen for a variety of reasons, but there is one specific case that this catches: When the player pounces on two similar-height actors at the same time, the first actor that gets processed will be pounced successfully and impart a recoil. By the time the other actor is processed, the player state reflects a pounce already in progress – there is no reason to start another one.

This else if catches that case – isPlayerRecoiling is true because some other actor was already pounced during this tick, isPounceReady is true because the player is lined up to pounce on this other actor as well, and playerRecoilLeft is very close to the last value that was stashed in lastrecoil. (Actually they should be identical, but this adds a bit of a fudge factor.)

        ClearPlayerDizzy();

        if (playerRecoilLeft > 18) {
            isPlayerLongJumping = true;
        } else {
            isPlayerLongJumping = false;
        }

        pounceHintState = POUNCE_HINT_SEEN;

        return true;
    }

This is essentially a duplicate of the previous if body with the exception of the pounce streak bonus handling. In fact, everything that is done in here is gratuitous – the first actor already handled everything. Only the return true is significant.

    return false;
}

If neither of the above branches were taken, the pounce attempt failed. The return value of false informs the caller that no recoil was imparted, and none of the pounce effects should occur.


  1. This case is rare, but it was captured in one of the stock demo files. In episode one, when the demo reaches map four, there is a sequence of pounces at the start of the level: The player destroys five Baskets in four pounces, then continues on to pounce two Red Chomper Creatures without touching the ground. The first Red Chomper Creature is pounced while the player is still recoiling from the last Basket, even though they were no longer rising at that moment. ↩︎