Game Loop Functions

The game loop is the core code that executes once per frame of gameplay and dispatches all of the per-frame functionality needed to run each of the game’s subsystems. This loop is responsible for controlling the overall pace of gameplay, screen redrawing, input handling, and the movement of the player and all of the elements that exist in the game world. The game loop is also responsible for checking to see if the current level has been “won” and calling for a level change in that case.

The game loop runs continually until the entire episode has been won or the user quits the game.

Supporting Functions

Before the game loop begins, a pair of functions (InitializeEpisode() and InitializeLevel()) run to set up all of the global variables needed for the game to work. InitializeEpisode() is responsible for setting up the initial state of the game (so the player starts on the first level, with three filled health bars, etc.) when a fresh game is started. In the case where a saved game is loaded, LoadGameState() performs these actions using the saved values instead.

InitializeLevel() is responsible for loading the map data into memory and setting up the initial state of the level that is being entered. This function runs each time a level is entered – via regular level progression, loading a saved game, beginning a new game, or by the player dying and restarting the current level. In broad strokes, InitializeLevel() initializes all of the variables that are processed during each iteration of the game loop.

A third function, InitializeMapGlobals() is called by InitializeLevel() to set the initial state of many global variables pertaining specifically to player movement and map object interactivity.

InitializeEpisode()

The InitializeEpisode() function sets up the initial values for a few of the episode-related global variables tracked by the game. This sets up the initial score, health, level number, etc. when a new game is started. This places the player on the first level with a zero score, three full bars of health, and so forth.

This function is not used when a saved game is loaded; the save file contains its own values that should be restored. See LoadGameState() for that implementation.

void InitializeEpisode(void)
{
    gameScore = 0;
    playerHealth = 4;
    playerHealthCells = 3;
    levelNum = 0;
    playerBombs = 0;
    gameStars = 0;
    demoDataPos = 0;
    demoDataLength = 0;
    usedCheatCode = false;
    sawBombHint = false;
    sawHealthHint = false;
}

This function contains no logic; it simply sets the default initial values for all of the relevant game globals. gameScore, playerBombs, and gameStars are all set to zero, which sets the initial state of those elements on the status bar. playerHealth and playerHealthCells are set to four and three respectively, setting the player’s initial health to 4 (i.e. the player can be damaged four times without dying) and the number of health bars available in the status bar to 3.

By setting levelNum to zero, the first level is selected for play. (See level and map functions for information about the level progression.) demoDataPos and demoDataLength are both zeroed to ensure that any subsequent demo file playback or recording operations start in a sensible state.

Finally, the usedCheatCode, sawBombHint, and sawHealthHint flags are set to false. The usedCheatCode flag prevents the cheat key sequence from being used more than once during an episode, and the remaining flags determine if contextual hint dialogs should be shown as different events happen in the game. In this state, the game presumes that the user might not know how to use bombs or how to regain health.

Note: There is an additional hint variable pounceHintState that is not initialized here; that happened earlier in the “begin new game” branch of TitleLoop().

InitializeLevel()

The InitializeLevel() function initializes all of the global variables and related subsystems for the start (or restart) of the level identified by level_num. This function runs every time a level is entered, whether by starting a new game, playing a demo, loading a game, or restarting due to player death. This function handles the screen/music transitions, loads the level data, initializes the player, and sets up all of the interactive elements on the map.

void InitializeLevel(word level_num)
{
    FILE *fp;
    word bdnum;

    if (level_num == 0 && isNewGame) {
        DrawFullscreenImage(IMAGE_ONE_MOMENT);
        WaitSoft(300);
    } else {
        FadeOut();
    }

In the case when the level_num being entered is the zeroth level and the isNewGame flag is set, this function knows that the user chose to begin a new game (as opposed to loading a game or starting demo playback that might happen to be on the zeroth level). In this case, the DrawFullscreenImage() function displays IMAGE_ONE_MOMENT (an image of an alien, who we learn much later is Zonk, saying “One Moment”) and WaitSoft() inserts an artificial (but partially skippable) delay of 300 game ticks. If the user chooses to skip the delay, the subsequent code will take a perceptible amount of time to load the level data and construct the backdrop image tables, so the image will not immediately disappear on many computers.

I guess that’s growing up.

All my childhood, I wondered what the game was doing while it was showing this “One Moment” screen. I always figured it was doing some important calculation or data load operation, but it turns out it’s just sitting idle. In retrospect, I probably should’ve realized that it wasn’t doing anything critical since demos and saved games loaded without such a delay.

In all other cases, the screen immediately fades out due to a call to FadeOut() and the function continues with the screen blank.

    fp = GroupEntryFp(mapNames[level_num]);
    mapVariables = getw(fp);
    fclose(fp);

The passed level_num is looked up in the mapNames[] array to translate it into a group entry name. This name is passed to GroupEntryFp() which looks up the correct data in the group files and returns a file stream pointer to this data in fp.

The first two bytes in the map file format are map variables that control a few details about the global environment of the map. These are read with a call to getw() and stored in the 16-bit word variable mapVariables. Since this is the only part of the map data that needs to be processed here, fp is closed by the call to fclose().

Note: The map file will be reopened as part of the subsequent LoadMapData() call, which is a bit redundant and wasteful.

    StopMusic();

    hasRain = (bool)(mapVariables & 0x0020);
    bdnum = mapVariables & 0x001f;
    hasHScrollBackdrop = (bool)(mapVariables & 0x0040);
    hasVScrollBackdrop = (bool)(mapVariables & 0x0080);
    paletteAnimationNum = (byte)(mapVariables >> 8) & 0x07;
    musicNum = (mapVariables >> 11) & 0x001f;

StopMusic() stops any menu or in-game music that might be playing. It has to happen somewhere, may as well be here.

Next, the mapVariables are decoded. The 16-bit value is packed according to the map variables table, and its bit fields are extracted into the boolean hasRain, hasHScrollBackdrop, and hasVScrollBackdrop variables, while the numeric fields are stored in bdnum, paletteAnimationNum, and musicNum. The bdnum variable contains the map’s backdrop number, which is handled locally and does not get stored in any global variables here.

    InitializeMapGlobals();

InitializeMapGlobals() is responsible for initializing almost four dozen global variables pertaining to player movement, actor interaction, and map state. There is no logic performed in there; all of these variables are unconditionally set to the same initial value every time this function is called.

    if (IsNewBackdrop(bdnum)) {
        LoadBackdropData(backdropNames[bdnum], mapData.b);
    }

Here the backdrop data is loaded and the scrolling preprocessing is done if needed. This is governed by calling IsNewBackdrop() with the map’s bdnum as the argument. If the new backdrop is the same as the backdrop that has previously been loaded, there is no reason to load it again and the if body does not execute. Otherwise, LoadBackdropData() is called to prepare the new backdrop for use. The backdrop’s group file name is looked up in the backdropNames[] array by using bdnum as the index.

LoadBackdropData() requires a block of memory to use as scratch space, and the byte-addressable view into mapData is large enough to serve that purpose. After the load is complete, mapData and its contents are no longer needed and it can be refilled with the actual, correct map data.

    LoadMapData(level_num);

    if (level_num == 0 && isNewGame) {
        FadeOut();
        isNewGame = false;
    }

The actual loading of the map data and the construction of the actors it contains is handled in the LoadMapData() function. It takes the level_num as an argument to specify which level to load.

Similar to the beginning of the function, a special condition checks for the case where a new game has been started. In this case, the “One Moment” image is still visible on the screen, so FadeOut() takes it down. To prevent it from showing again if this level restarts, the isNewGame flag is unset here.

    if (demoState == DEMO_STATE_NONE) {
        switch (level_num) {
        case 0:
        case 1:
        case 4:
        case 5:
        case 8:
        case 9:
        case 12:
        case 13:
        case 16:
        case 17:
            SelectDrawPage(0);
            SelectActivePage(0);
            ClearScreen();
            FadeIn();
            ShowLevelIntro(level_num);
            WaitSoft(150);
            FadeOut();
            break;
        }
    }

This block of code handles UI feedback in the form of the “Now entering level…” text before each level begins. If demoState is DEMO_STATE_NONE, a demo is neither being recorded nor played back and such feedback is appropriate. The switch cases mirror the level progression of the game. This ensures that this dialog only appears before maps 1–10, without appearing before bonus levels or the eleventh level of the first episode.

For these regular levels, the dialog is shown by calling both SelectDrawPage() and SelectActivePage() with the zeroth video page as the argument. This makes it so that the effect of all draw functions becomes immediately visible without the page-flipping machinery getting in the way. ClearScreen() erases this draw page by replacing it with solid black tiles, and FadeIn() restores the palette to its normal state, making this solid black screen visible.

ShowLevelIntro() displays the “Now entering level…” dialog with level_num used to influence the number shown in the message. WaitSoft() pauses on the message for an interruptible 150 game ticks, then FadeOut() fades the message away. From there, execution breaks back to the main flow of the function.

    InitializeShards();
    InitializeExplosions();
    InitializeDecorations();
    ClearPlayerPush();
    InitializeSpawners();

These functions initialize the actor-like elements that all maps can have, as well as a bit of player movement.

InitializeShards(), InitializeExplosions(), InitializeDecorations(), and InitializeSpawners() reset the fixed-size arrays for the map’s shards, explosions, decorations, and spawners, respectively. Each level should start with none of these in an active state, so this clears any such elements that might have been left running from the previous map.

ClearPlayerPush() resets the global player control variables that come into play when the player is involuntarily pushed around the map. Each map should begin with the player in a “not pushed” state, and this function achieves that.

    ClearGameScreen();
    SelectDrawPage(activePage);
    activePage = !activePage;
    SelectActivePage(activePage);

Next the in-game drawing environment is set up. ClearGameScreen() erases the screen and draws the status bar background and current score/bombs/health/stars values on top of it. This sets up the static areas of the game screen on both video pages.

The initial state of the page-flipping is set up next. In steady-state, the value in activePage represents the page number that is currently being displayed on the screen, leaving the opposite page number (the “draw” page) to be the one being redrawn in the background. This sequence reverses the pages’ roles: SelectDrawPage() tells drawing to occur on the (old) active page, then activePage is negated to produce the (new) active page, and SelectActivePage() displays this (new) active page.

Visually this does not do anything at this point in the execution, since both pages contain identical content, but it does ensure that the pages are in a reasonable state for the game loop to begin drawing and flipping pages itself later. The GameLoop() function performs these same steps at the end of each frame it draws.

    SaveGameState('T');
    StartGameMusic(musicNum);

SaveGameState() saves a snapshot of the current level’s global variables into the temporary save slot (identified by the character 'T'), which defines the restore point that will be subsequently used if the player dies and the level needs to restart with the score/health/etc. that the player had when they initially entered.

StartGameMusic() loads and begins playing the map’s chosen music (identified by musicNum) from the beginning. If there is no AdLib hardware installed or the music is disabled, this function skips doing some of that work.

    if (!isAdLibPresent) {
        tileAttributeData = miscData + 5000;
        miscDataContents = IMAGE_TILEATTR;
        LoadTileAttributeData("TILEATTR.MNI");
    }

This code is the inverse implementation of a similar fragment found near the end of Startup(). In this case, the if body is executed when the system does not have an AdLib card installed (isAdLibPresent is false). The reason for this separation is for reasons of memory efficiency: If the system does not have an AdLib card, there is no need to load any music data into the memory backed by miscData and that space can be repurposed to hold tile attribute data instead. (Otherwise, when miscData is holding music, there is a separate allocation of memory that tileAttributeData permanently points to.)

Here miscData points to a 35,000 byte arena of memory, where the first 5,000 bytes are reserved for demo data that may be playing or being recorded. The tileAttributeData pointer is set to the first byte past the end of this data. miscDataContents is set to IMAGE_TILEATTR to mark that this memory has been claimed for this purpose. Finally, LoadTileAttributeData() loads the data from the named group entry into this memory.

    FadeIn();

At this point in the execution, the screen is faded to black via palette manipulation, but the video memory contains a black game window with an initialized status bar at the bottom of the screen. The call to FadeIn() fades this into view. FadeIn() blocks until completion, so the initialization does not proceed (and the game loop does not start) until after it returns.

#ifdef EXPLOSION_PALETTE
    if (paletteAnimationNum == PAL_ANIM_EXPLOSIONS) {
        SetPaletteRegister(PALETTE_KEY_INDEX, MODE1_BLACK);
    }
#endif
}

There is one last bit of palette manipulation that occurs in episode three, which is the only episode with the EXPLOSION_PALETTE macro defined. In this episode, the level’s paletteAnimationNum is checked to see if it matches PAL_ANIM_EXPLOSIONS, in which case the special “flash during explosions” palette animation mode is activated. In this mode, all magenta areas of the screen show as black by default, but flash bright white and yellow while explosions occur. To facilitate the initial state for this mode, SetPaletteRegister() is called to set the palette register named by PALETTE_KEY_INDEX (which represents magenta areas in the game’s graphics) to the EGA’s MODE1_BLACK color.

InitializeMapGlobals()

The InitializeMapGlobals() function resets many of the global variables pertaining to player movement and map/actor interactivity. This function is called whenever a level begins and ensures it has a consistent and clean state, with nothing from the previous level carried over.

void InitializeMapGlobals(void)
{
    winGame = false;
    playerClingDir = DIR4_NONE;
    isPlayerFalling = true;
    cmdJumpLatch = true;
    playerJumpTime = 0;
    playerFallTime = 1;
    isPlayerRecoiling = false;
    playerRecoilLeft = 0;
    playerFaceDir = DIR4_EAST;
    playerFrame = PLAYER_WALK_1;
    playerBaseFrame = PLAYER_BASE_EAST;
    playerDeadTime = 0;
    winLevel = false;
    playerHurtCooldown = 40;
    transporterTimeLeft = 0;
    activeTransporter = 0;
    isPlayerInPipe = false;
    scooterMounted = 0;
    isPlayerNearTransporter = false;
    isPlayerNearHintGlobe = false;
    areForceFieldsActive = true;
    blockMovementCmds = false;

    ClearPlayerDizzy();

    blockActionCmds = false;
    arePlatformsActive = true;
    isPlayerInvincible = false;
    paletteStepCount = 0;
    randStepCount = 0;
    playerFallDeadTime  = 0;
    sawHurtBubble  = false;
    sawAutoHintGlobe = false;
    numBarrels = 0;
    numEyePlants = 0;
    pounceStreak = 0;

    sawJumpPadBubble =
        sawMonumentBubble =
        sawScooterBubble =
        sawTransporterBubble =
        sawPipeBubble =
        sawBossBubble =
        sawPusherRobotBubble =
        sawBearTrapBubble =
        sawMysteryWallBubble =
        sawTulipLauncherBubble =
        sawHamburgerBubble = false;
}

This function contains no logic and behaves identically in every context where it is run. It sets the following variables:

  • winGame is set to false, ensuring that the player must reach an episode-specific goal to complete the game and see the end story.
  • playerClingDir is set to DIR4_NONE, indicating that the player is not currently clinging to any walls. In this state, the player is standing on solid ground (or possibly free-falling toward it).
  • isPlayerFalling is set to true, which simplifies the interaction between the player’s starting position and the various movement variables that control the player. The game assumes that the player starts every map in empty space, ready to free-fall. Whenever the player is free-falling, the MovePlayer function continually tries to pull the player down until they land on a solid map tile. If the player’s start position should happen to be on such a map tile already, the free-fall will be canceled and the player will be switched to a standing state immediately.
  • cmdJumpLatch = true
  • playerJumpTime = 0
  • playerFallTime = 1
  • isPlayerRecoiling = false
  • playerRecoilLeft = 0
  • playerFaceDir is set to DIR4_EAST, which makes the player start each level facing east. By convention, the player usually starts toward the left side of the map, looking in the direction they need to travel to progress.
  • playerFrame is set to PLAYER_WALK_1, but this assignment is not that important since this value will be immediately overwritten with the correct standing/falling frame during the next call to MovePlayer.
  • playerBaseFrame is set to PLAYER_BASE_EAST, following the same motivations as playerFaceDir above.
  • playerDeadTime is set to zero, indicating that the player has not died yet.
  • winLevel is set to false, ensuring that the player must reach a map-specific goal to complete the level and progress to the next one.
  • playerHurtCooldown is initialized to 40. This makes the player invincible for roughly the first four seconds of gameplay to allow them to get situated in their surroundings without taking damage. This invincibility is accompanied by the player sprite flashing.
  • transporterTimeLeft is set to zero, since the player is not using any transporters at the start of the level.
  • activeTransporter is set to zero, indicating that there is currently no transporter actor being interacted with.
  • isPlayerInPipe is set to false, conceptually placing the player “outside” the pipe system where the pipe corner actors will have no effect.
  • scooterMounted is set to zero, ensuring the player enters every map without a scooter.
  • isPlayerNearTransporter and isPlayerNearHintGlobe are both set to false, since the player should not be touching either of these actor types when entering a level.
  • areForceFieldsActive is set to true. The game’s design is such that force fields are always active when a level is started, and can only be deactivated by using the Foot Switch (deactivates force fields).
  • blockMovementCmds is set to false, allowing the player to walk and jump freely.
  • ClearPlayerDizzy() is called to reset the state variables pertaining to the “dizzy” immobilization the player can sometimes experience.
  • blockActionCmds is set to false, allowing the player to be moved freely.
  • arePlatformsActive is set to true, specifying that all platforms on the map are active and moving by default. As the map is loaded, the presence of a Foot Switch (activates platforms) may turn this off, requiring the player to find and interact with that switch to make the platforms run again.
  • isPlayerInvincible is set to false, since the player will not have an Invincibility Sphere to start with.
  • paletteStepCount is set to zero, ensuring that any palette animations used on the current map start at the beginning of their color sequence.
  • randStepCount is set to zero, which resets the pseudorandom number generator in GameRand() to a predictable and stable state. This is critically important to ensure that actors that use randomness behave identically during demo recording and playback.
  • playerFallDeadTime is set to zero, indicating that the player has not fallen off the bottom of the map yet.
  • sawHurtBubble is set to false, allowing the “OUCH!” bubble to show once the player is hurt for the first time.
  • sawAutoHintGlobe is set false, which will auto-activate the first hint globe the player happens to touch.
  • numBarrels and numEyePlants are both set to zero. These variables track the number of Barrels/Baskets and the number of Eye Plants on the map, respectively. Since each map initially loads with zero actors of any type, zero is appropriate here.
  • pounceStreak is set to zero, eliminating any chance of a carryover of the previous map’s pounce streak.
  • All of the speech bubble flags – sawJumpPadBubble, sawMonumentBubble, sawScooterBubble, sawTransporterBubble, sawPipeBubble, sawBossBubble, sawPusherRobotBubble, sawBearTrapBubble, sawMysteryWallBubble, sawTulipLauncherBubble, and sawHamburgerBubble – are set to false. This re-enables the speech bubbles (generally “WHOA!” but occasionally “UMPH!”) that appear when the player first interacts with each of these actor types.

GameLoop()

The GameLoop() function runs once for each frame of gameplay and is responsible for running the appropriate sub-functions for timing, input, player/actor movement, world drawing, and level exit conditions. This function takes a demo_state argument which should be one of the DEMO_STATE_* constants – this controls the presence of the “DEMO” overlay sprite and is passed through to the input handling functions.

The game loop is structured as a true infinite loop that can only terminate under the following conditions:

  1. The user presses one of the “quit game” keys and confirms their choice at the prompt. In this case the loop exits immediately with a return statement.
  2. The episode is “won.” In this case, the loop exits due to a break statement and the ending story is shown before this function returns.

In either case, this function returns back to the title loop in the InnerMain() function.

Overall Structure

void GameLoop(byte demo_state)
{
    for (;;) {
        while (gameTickCount < 13)
            ;  /* VOID */

        gameTickCount = 0;

        /* ... Sub-function calls are discussed below ... */

        if (winLevel) {
            winLevel = false;
            StartSound(SND_WIN_LEVEL);
            NextLevel();
            InitializeLevel(levelNum);
        } else if (winGame) {
            break;
        }
    }

    ShowEnding();
}

Only the outermost structure of the game loop is shown here for clarity. Almost the entire function is contained inside the body of an infinite for loop. On each iteration, the value of gameTickCount is checked, and execution only proceeds once the count reaches 13. (This counter is incremented externally in the PCSpeakerService() function, which is called by a timer interrupt 140 times per second.) This busy loop ensures that the game waits at least 13 ⁄ 140 seconds between successive frames, effectively creating a frame-rate limiter a bit shy of 11 frames per second. Once the requisite amount of time has passed, gameTickCount is reset to zero to set up for the delay on the subsequent iteration and the rest of the game loop body runs.

For a fast computer that can draw a frame quickly, this loop eats up the remaining unused time before another loop iteration is allowed to start. On a very slow computer, it’s possible for gameTickCount to be at or above 13 by the time the next game loop iteration is entered, resulting in no busy wait at all. In these cases, the gameplay will appear noticeably sluggish because there are no mechanisms in place to skip frames or adjust the movement speeds of objects.

After the timing loop, the sub-functions are called. They are discussed below.

Near the end of the loop body, a check is made against the winLevel flag. If it holds a true value, the player interacted with a level-winning object on the map during the current iteration. When this happens, the winLevel is set back to false, the SND_WIN_LEVEL fanfare is queued via a call to StartSound(), and the next level number is selected by NextLevel(). This has the side-effect of changing the value in levelNum. That new level number is passed to InitializeLevel(), which loads the new map data and reinitializes all of the relevant global state to make it playable from the beginning. On the next iteration of the game loop, the player will be in the starting position of that new map with fresh actors to face.

A different exit condition can be reached when winGame is true, in which case the player has qualified to win the entire episode of the game during the current iteration of the game loop. In this case, execution breaks out of the infinite for loop and falls to the ShowEnding() call. This shows the relevant end story for the episode being played, and the GameLoop() function returns once the story has been shown to completion.

Sub-Functions

During each iteration of the outer infinite for loop, after the timing loop and before the end-level checks, the bulk of the function calls occur. There is very little logic done in the game loop itself; almost everything is handled in a function specific to each of the game’s subsystems.

        AnimatePalette();

If the current map has enabled one of the palette animation modes, each call to AnimatePalette() will “step” the animation to the next color defined in the sequence and change the visual representation of the magenta areas on the screen.

        {  /* for scope */
            word result = ProcessGameInputHelper(activePage, demo_state);
            if (result == GAME_INPUT_QUIT) return;
            if (result == GAME_INPUT_RESTART) continue;
        }

        MovePlayer();

        if (scooterMounted != 0) {
            MovePlayerScooter();
        }

        if (queuePlayerDizzy || playerDizzyLeft != 0) {
            ProcessPlayerDizzy();
        }

This section handles the input devices (keyboard, joystick, or the demo system) and moves the player through the map in response.

ProcessGameInputHelper() performs input handling based on the value passed in demo_state. In the case where a demo is being played back, input is ignored except as a signal to quit. Otherwise input is accepted and used for player movement. ProcessGameInputHelper() can also display menus and dialogs. To do this it must bypass the usual page-flipping mechanism and draw directly to the video page in activePage – that is why it is passed here.

ProcessGameInputHelper() returns a result code that can exit or restart the game loop. In the case of GAME_INPUT_QUIT, the user has requested to quit the game with Esc or Q, finished/dismissed the playback of a demo, or ran out of space while recording a demo. For these cases, return from the game loop immediately. For GAME_INPUT_RESTART, either a saved game was restored or the level warp cheat was used. In either case, the global state of the game world has changed and the game loop must be restarted from the top; continue accomplishes this.

The call to MovePlayer interprets the raw movement commands read from the input device and uses them to adjust the position of the player in the game world. This is a heinously complicated function with one simple to understand precondition – if the player happens to be riding a scooter, it returns without doing anything.

The game loop checks the player’s scooter state by reading scooterMounted. If it has a nonzero value, the scooter is active and MovePlayerScooter should be called to invoke the alternate, slightly simpler player movement logic.

The player can sometimes become temporarily “dizzy” by exiting a pipe system or falling from a great distance. This condition can be detected by either the queuePlayerDizzy flag being true or the playerDizzyLeft counter having a nonzero value. In either case, the ProcessPlayerDizzy() function is called to do the per-frame bookkeeping for this state.

        MovePlatforms();
        MoveFountains();

The combination of MovePlatforms() and MoveFountains() calls take care of moving map elements that the player can stand and walk on. These movements need to happen relatively early because the player’s position can be adjusted if they happen to be standing on top of one of these during this frame.

        DrawMapRegion();
        if (ProcessPlayer()) continue;
        DrawFountains();

This is the first point during a regular iteration of the game loop where something is drawn to the screen. DrawMapRegion() draws the entire map visible map area – solid tiles, masked tiles, and the backdrop behind empty areas – over the game window based on the current X and Y scroll position. This fully erases everything that was left over in the video memory on the draw page. Platforms are regular solid tiles in the map memory that move around, so they are drawn as part of this function. Fountains contain no visible map tiles, so they are hidden at this point. (The player can still stand on these invisible tiles, however.)

The ProcessPlayer() ultimately draws the player sprite onto the screen, but also does some per-frame checks to handle the player’s response to being hurt. Most notably, if the player dies or falls off the map, ProcessPlayer() reloads the current level and returns true to request a game loop restart (continue). Anything drawn after this point can cover the player sprite, including all actors and visual effects.

DrawFountains() draws the necessary sprite tiles onto the screen to show any fountains within the scrolling game window, even if the player is not currently interacting with any.

        MoveAndDrawActors();
        MoveAndDrawShards();
        MoveAndDrawSpawners();
        DrawRandomEffects();
        DrawExplosions();
        MoveAndDrawDecorations();
        DrawLights();

This is the “everything else” section of object movement and drawing. Some objects do not move (e.g. explosions and lights) but all are drawn at this time, in this order:

  • MoveAndDrawActors() updates the position of every “awake” actor on the map, including ones that may be off a screen edge. Sprites are drawn for those visible within the scrolling game window.
  • MoveAndDrawShards() updates the positions and draws all the sprites belonging to any shards that are visible. Those that are fully off the screen are stopped and removed.
  • MoveAndDrawSpawners() updates and draws all spawning objects, and creates new actor objects once each spawner’s lifecycle is complete.
  • DrawRandomEffects() draws random sparkles on slippery areas of the map, and spawns raindrops if the map requires them.
  • DrawExplosions() animates a successive frame of each active explosion visible on the screen and draws the corresponding sprite.
  • MoveAndDrawDecorations() handles the movement, animation, and looping properties of every decoration visible inside the game window. The sprite for each is drawn in the process. Decorations are stopped and removed once their loop count runs out or they leave the visible screen area.
  • DrawLights() handles the special map actors that represent lighted areas of the map. Such lights shine “down” the screen and continue until they hit a solid map tile (or they reach a hard-coded maximum distance). Anything currently drawn on the screen at this point can be “lightened” if one of these light beams touches it.
        if (demoState != DEMO_STATE_NONE) {
            DrawSprite(SPR_DEMO_OVERLAY, 0, 18, 4, DRAW_MODE_ABSOLUTE);
        }

If a demo is being recorded or played back, demoState will have a value other than DEMO_STATE_NONE. In this case, DrawSprite() draws frame zero of the SPR_DEMO_OVERLAY “DEMO” sprite on the screen. This call uses the screen-relative DRAW_MODE_ABSOLUTE, so the coordinates (18, 4) represent a fixed position near the center-top of the game window. This is the last thing drawn during a typical iteration of the game loop, and nothing should subsequently cover it.

        SelectDrawPage(activePage);
        activePage = !activePage;
        SelectActivePage(activePage);

This is the page-flipping. Up until this point, all of the drawing had been directed at the “draw” page, which is the opposite of the “active” page that is currently being shown on the screen. SelectDrawPage() is called with activePage as the argument, informing the drawing functions that they should subsequently draw to the area of video memory that is currently on the screen.

Immediately after that, activePage is inverted to select the page that is not targeted by the drawing functions. This value is then passed to SelectActivePage(), which switches the content on the screen to the opposite page. This immediately makes visible everything that had been drawn in this iteration of the game loop, while also giving the next iteration of the game loop an area of video memory where it can perform its drawing work without it being visible on the screen.

        if (pounceHintState == POUNCE_HINT_QUEUED) {
            pounceHintState = POUNCE_HINT_SEEN;
            ShowPounceHint();
        }

A conceivably late addition to the game loop is this pounce hint check. If the value in pounceHintState is equal to POUNCE_HINT_QUEUED, something occurred during the current iteration of the game loop to warrant displaying the pounce hint dialog. Update pounceHintState to POUNCE_HINT_SEEN so this cannot recur, then call ShowPounceHint() to show the dialog.

Queue you!

The queuing implementation used here is probably not necessary; the behavior of the health hints in InteractPlayer() demonstrates a more direct way to do this. The placement here suggests that maybe an older version of this function interacted badly with the page-flipping, perhaps appearing and pausing gameplay with some of the actors not yet drawn for the current frame.

ProcessGameInputHelper()

The ProcessGameInputHelper() function is a small wrapper around the ProcessGameInput() function that prepares the video hardware for the possibility of showing a menu or dialog during the game loop. active_page should contain the “active” (i.e. currently displayed) video page number, and demo_state should hold one of the DEMO_STATE_* values.

byte ProcessGameInputHelper(word active_page, byte demo_state)
{
    byte result;

    EGA_MODE_LATCHED_WRITE();

    SelectDrawPage(active_page);

    result = ProcessGameInput(demo_state);

    SelectDrawPage(!active_page);

    return result;
}

This function is effectively a wrapper around ProcessGameInput(), passing the demo_state value directly through and returning its result without modification. This function exists to prepare the video hardware for certain drawing operations that ProcessGameInput() may need to do.

EGA_MODE_LATCHED_WRITE() puts the EGA hardware into “latched” write mode. This is the mode used when drawing solid tile images, since these are stored in the EGA’s onboard memory and need to use the latches to copy data onto a video page.

Work harder, not smarter.

The call to EGA_MODE_LATCHED_WRITE() is arguably not needed, since all of the dialog/menu functions that can be invoked during the game ultimately call DrawTextFrame() which sets the EGA mode anyway. It could also be argued that, since most of the frames drawn by the game loop never show a menu or dialog in the first place, performing this call on every loop iteration is simply a waste of clock cycles.

The call to SelectDrawPage() sets the draw page – that is, the area of video memory that is visible on the screen right now – to active_page. This sets it up so that any subsequent drawing operation goes directly and immediately to the screen, visible to the user without page flipping coming into play.

In this state, anything drawn inside ProcessGameInput() and the functions it calls are immediately visible.

Once the input handling is done, SelectDrawPage() is called again with the inverse of page as the argument. This resets the draw page back to the way it was when the function was first entered, and leaves the hardware in the “draw everything in a hidden buffer” mode it expects.

Finally, the original return value of ProcessGameInput(), stashed in result, is returned to the caller.

ProcessGameInput()

The ProcessGameInput() function handles all keyboard and joystick input while the game is being played. Depending on the value passed in demo_state (which should be one of the DEMO_STATE_* values), this behavior is modified to record or play back demo data. Returns one of the GAME_INPUT_* values to control the game loop’s flow.

In addition to setting up player movement, this function also calls the in-game menus and dialogs for game options and cheat keys.

byte ProcessGameInput(byte demo_state)
{
    if (demo_state != DEMO_STATE_PLAY) {

This function does the most work when the passed demo_state is not DEMO_STATE_PLAY. This state is encountered when the player is playing the game normally or recording a demo. Later the else block will handle the demo playback case.

        if (
            isKeyDown[SCANCODE_TAB] && isKeyDown[SCANCODE_F12] &&
            isKeyDown[SCANCODE_KP_DOT]  /* Del */
        ) {
            isDebugMode = !isDebugMode;
            StartSound(SND_PAUSE_GAME);
            WaitHard(90);
        }

If the user is pressing the Tab + F12 + Del / Num . debug key combination during the current frame, they want to toggle the state of the debug mode. The key state is read from the isKeyDown[] array indexed by the appropriate SCANCODE_* values, with the “delete” key sharing a scancode with the “dot” on the numeric keypad. When this key combination is down, isDebugMode is toggled and StartSound() plays the SND_PAUSE_GAME effect to give feedback that the input was accepted.

To give the user a chance to release the keys without unintentionally toggling the debug mode further, WaitHard() pauses the entire game for a bit over half a second.

        if (isKeyDown[SCANCODE_F10] && isDebugMode) {

This if block handles the F10 + … debug keys. isDebugMode must be true for F10 to have significance to the game.

            if (isKeyDown[SCANCODE_G]) {
                ToggleGodMode();
            }

In the case of F10 + G, the user wants to toggle the state of the god mode cheat. ToggleGodMode() does that and displays the dialog showing the result of the change.

            if (isKeyDown[SCANCODE_W]) {
                if (PromptLevelWarp()) return GAME_INPUT_RESTART;
            }

For In the case of F10 + W, the user wants to warp to a different level. PromptLevelWarp() collects that input. If the user cancels or enters a nonsense value, PromptLevelWarp() will return false and execution here will continue uninterrupted.

In the case where the user selected a valid level number, that level will be loaded and initialized then PromptLevelWarp() returns true. In that case, GAME_INPUT_RESTART is returned here to indicate that the game loop must restart on this new level.

            if (isKeyDown[SCANCODE_P]) {
                StartSound(SND_PAUSE_GAME);
                while (isKeyDown[SCANCODE_P])
                    ;  /* VOID */
                while (!isKeyDown[SCANCODE_P])
                    ;  /* VOID */
                while (isKeyDown[SCANCODE_P])
                    ;  /* VOID */
            }

When the user presses F10 + P, the game pauses without any UI elements covering up any part of the screen. StartSound() plays the SND_PAUSE_GAME effect as an indication that this is a pause and not some kind of program crash, then a series of while loops is entered.

The first while loop spins, performing no work for as long as the P key is pressed. This absorbs the initial keypress that brought the program into the pause mode. The keyboard interrupt handler is called as keys are pressed and released, which updates the values in the isKeyDown[] array while this loop runs.

The second while loop comprises the bulk of the pause. The program waits here for as long as the user wants to stay.

The third while loop is entered when the P key is pressed again (it does not need to be accompanied with F10 here). This keeps the game paused while P is pressed this second time, unpausing and resuming execution as soon as the key is released.

The combined effect of these three loops is a latch. The game pauses on the first press of the F10 + P combination, and does not unpause until the P key is pressed and released a subsequent time.

            if (isKeyDown[SCANCODE_M]) {
                ShowMemoryUsage();
            }

In the case of F10 + M, the user wants to see the memory usage statistics. ShowMemoryUsage() handles that display.

            if (
                isKeyDown[SCANCODE_E] &&
                isKeyDown[SCANCODE_N] &&
                isKeyDown[SCANCODE_D]
            ) {
                winGame = true;
            }
        }

If the user presses the (convoluted) F10 + E + N + D combination, the episode is immediately “won” by setting winGame to true, informing the game loop that it should stop running and instead display the ending story.

This is the final check for F10 debug keys, and the if block from above ends.

        if (
            isKeyDown[SCANCODE_C] &&
            isKeyDown[SCANCODE_0] &&
            isKeyDown[SCANCODE_F10] &&
            !usedCheatCode
        ) {
            StartSound(SND_PAUSE_GAME);
            usedCheatCode = true;
            ShowCheatMessage();
            playerHealthCells = 5;
            playerBombs = 9;
            sawBombHint = true;
            playerHealth = 6;
            UpdateBombs();
            UpdateHealth();
        }

This is the C + 0 + F10 customer cheat, invoked by seeing the correct combination of keys in the isKeyDown[] array. usedCheatCode must be false here, which prevents the user from requesting this cheat more than once during the course of an episode.

StartSound() queues the SND_PAUSE_GAME effect, which continues playing on top of the subsequent cheat message. usedCheatCode is set to true, making this a one-shot operation unless the episode is restarted. The call to ShowCheatMessage() explains what is happening before further changes occur.

Once the message is dismissed, the player is given five bars of available health when playerHealthCells is set to 5. These bars become filled when playerHealth is set to 6. The player is also given nine bombs (the maximum permitted) with playerBombs= 9. This also sets the sawBombHint flag, which prevents the bomb hint from showing again during the episode. (Usually, this hint is disabled when the player picks up their first bomb – the assignment here covers the case where the player cheated before picking any up.)

After the changes are made, a pair of calls to UpdateBombs() and UpdateHealth() makes the changes visible in the status bar.

        if (isKeyDown[SCANCODE_S]) {
            ToggleSound();
        } else if (isKeyDown[SCANCODE_M]) {
            ToggleMusic();
        } else if (isKeyDown[SCANCODE_ESC] || isKeyDown[SCANCODE_Q]) {
            if (PromptQuitConfirm()) return GAME_INPUT_QUIT;

These are the in-game keys. At any point during gameplay, the S key toggles sound, M toggles music, and Esc/Q brings up a “quit game” confirmation.

Each of these keys is checked via the isKeyDown[] array, indexed by the selected SCANCODE_* value. Sound and music is toggled (and the resulting state shown) by the ToggleSound() and ToggleMusic() functions, respectively.

The quit confirmation is shown in PromptQuitConfirm(), and returns true if the user said yes. This returns GAME_INPUT_QUIT to the game loop, terminating it. If the user cancels, nothing is returned here and execution continues.

        } else if (isKeyDown[SCANCODE_F1]) {
            byte result = ShowHelpMenu();
            if (result == HELP_MENU_RESTART) {
                return GAME_INPUT_RESTART;
            } else if (result == HELP_MENU_QUIT) {
                if (PromptQuitConfirm()) return GAME_INPUT_QUIT;
            }

This block handles the case where the F1 key is pressed. This is advertised in the status bar as the “help” key, which presents a menu with a few configuration options and help screens. ShowHelpMenu() is responsible for showing this menu and dispatching to all of the available sub-options. When the menu is dismissed, a result byte is returned.

The game menu is capable of changing the level being played (by restoring a saved game) or quitting the game. These states need to be passed up to the game loop, which could restart or terminate based on this information.

Internally, there is a mismatch in the numbering scheme used by HELP_MENU_* and GAME_INPUT_*, which is why the result value needs to be translated. HELP_MENU_RESTART becomes GAME_INPUT_RESTART while HELP_MENU_QUIT – if confirmed by PromptQuitConfirm() – becomes GAME_INPUT_QUIT.

In other cases, result contains a value that needs no special handling, and execution continues with no further modification of the game loop’s execution flow.

        } else if (isKeyDown[SCANCODE_P]) {
            StartSound(SND_PAUSE_GAME);
            ShowPauseMessage();
        }

The final game key is P, which pauses the game with a sound effect and a visible message. This is produced by a combination of StartSound() (SND_PAUSE_GAME) and the accompanying ShowPauseMessage().

    } else if ((inportb(0x0060) & 0x80) == 0) {
        return GAME_INPUT_QUIT;
    }

This is the else branch of the demo_state check near the start of the function. This path is taken when a demo is being played back. The condition is a copy of the IsAnyKeyDown() implementation, with the technical details discussed in the documentation for that function.

This causes GAME_INPUT_QUIT to be returned to the game loop when any key is pressed, providing a way for the user to quit demo playback at any point by pressing a key.

    if (demo_state != DEMO_STATE_PLAY) {

This is a duplicate of the larger if in the first half of the function – running the first body if the game is being played normally (or a demo is being recorded), and the second body while playing a demo back.

        if (!isJoystickReady) {
            cmdWest  = isKeyDown[scancodeWest] >> blockMovementCmds;
            cmdEast  = isKeyDown[scancodeEast] >> blockMovementCmds;
            cmdJump  = isKeyDown[scancodeJump] >> blockMovementCmds;
            cmdNorth = isKeyDown[scancodeNorth];
            cmdSouth = isKeyDown[scancodeSouth];
            cmdBomb  = isKeyDown[scancodeBomb];
        } else {
            ReadJoystickState(JOYSTICK_A);
        }

If isJoystickReady is false, the user is not using joystick input for player movement and the keyboard should be used instead. For each movement command, the configured scancode index (scancodeWest, scancodeEast, scancodeJump, scancodeNorth, scancodeSouth, and scancodeBomb) is read from the isKeyDown[] array and the key up/down state becomes the inactive/active state of that movement command.

cmdWest, cmdEast, and cmdJump have an additional processing step: If blockMovementCmds holds a nonzero (i.e. not-false) value, the boolean isKeyDown[] value is bitwise-shifted to the right. Since boolean false/true is equivalent to integer zero/nonzero, this functions as a boolean AND NOT expression – the command variable is set if the key is down and commands are not blocked. cmdNorth, cmdSouth, and cmdBomb are not affected in this way because the player doesn’t conceptually “move” for those inputs.

In the opposite case, isJoystickReady is true and joystick input is being used. Don’t process keyboard input at all, and instead call ReadJoystickState() to read the movement commands from JOYSTICK_A. Although ReadJoystickState() returns a JoystickState structure with button press info, that return value is not used here – all global variables are set without considering the return value.

Note: There is a bug here due to blockMovementCmds never being checked. It is possible for the player to walk or jump out of situations when using the joystick that they would not be able to while using the keyboard.

        if (blockActionCmds) {
            cmdNorth = cmdSouth = cmdBomb = false;
        }

Regardless of the input device, if blockActionCmds is true, the cmdNorth, cmdSouth, and cmdBomb variables are all forced off, preventing the user from doing these actions.

        if (demo_state == DEMO_STATE_RECORD) {
            if (WriteDemoFrame()) return GAME_INPUT_QUIT;
        }

If the user is recording a demo, demo_state will have the value DEMO_STATE_RECORD. This doesn’t significantly change input handling; most inputs are processed identically. The difference here is that WriteDemoFrame() is called during each frame to capture the state of the input commands.

WriteDemoFrame() usually returns false, but can return true if the demo has been running for so long that the entire buffer has been filled (this takes seven to eight minutes to accomplish). When this happens, GAME_INPUT_QUIT tells the game loop to immediately quit – nothing more can be stored.

    } else if (ReadDemoFrame()) {
        return GAME_INPUT_QUIT;
    }

This is the opposite branch of the demo_state check, and is reached when a demo is being played back. In this case, ReadDemoFrame() is called on every frame to fill the movement command variables from the stream of demo data.

ReadDemoFrame() usually returns false, but will return true once the last frame of demo data has been read – this is the end of the recording. When that happens, GAME_INPUT_QUIT tells the game loop to immediately quit – the demo is over.

    return GAME_INPUT_CONTINUE;
}

If execution ended up here, nothing special occurred during this frame: the user did not quit, the level was not changed, and a demo did not end. GAME_INPUT_CONTINUE tells the game loop that it should proceed with drawing the frame as usual.