Purple Martians
Technical Code Descriptions
Level Done Procedure
Overview
Variables
Modes
How The Procedure Is Initiated
How The Procedure Progresses
Mode 9 and 8 - Players Seek Exit
Mode 7 - Players Shrink and Rotate into Exit
Mode 5 - Skippable Delay
Mode 1 - Load and start next level
Other Uses
End of Game Cutscene
Special Netgame Considerations
Conclusion and Notes
Overview
A level is completed when any player touches an unlocked exit.
The level is frozen and the end of level stats are shown.
Then after a timeout, or after all players acknowledge, the next level is loaded and started
The server controls the process using the following variables, which are synced to the clients with the rest of the game data.
Variables
These are the variables used in the process:
mPlayer.syn[0].level_done_mode
mPlayer.syn[0].level_done_timer
mPlayer.syn[0].level_done_ack
mPlayer.syn[0].level_done_x
mPlayer.syn[0].level_done_y
mPlayer.syn[0].level_done_player
mPlayer.syn[0].level_done_frame;
mPlayer.syn[0].level_done_next_level
'mode' is the current mode
'timer' controls the length of time before switching to the next mode
'ack' keeps track of which players have acknowledged
'x' and 'y' keep track of the exit position on the level
'player' keeps track of which player touched the exit
'frame' keeps track of the exact frame the level was completed
'next_level' is the next level to load after 'Level Done' is complete
Modes
These are the different modes:
level_done_mode = 9; // set up players seek xinc, yinc and direction
level_done_mode = 8; // all players move to the exit
level_done_mode = 7; // all players rotate and shrink into the exit
level_done_mode = 6; // not used, place holder for future features
level_done_mode = 5; // skippable 15s delay while level end stats show
level_done_mode = 4; // not used, place holder for future features
level_done_mode = 3; // not used, place holder for future features
level_done_mode = 2; // not used, place holder for future features
level_done_mode = 1; // set state to PM_PROGRAM_STATE_NEXT_LEVEL to load next level
level_done_mode = 0; // normal game play
How The Procedure Is Initiated
The procedure is initiated by setting 'level_done_mode', when an unlocked exit is touched by any player.
This can only be initiated from mode 0, to prevent re-triggering.
void mwItem::proc_exit_collision(int p, int i)
{
int exit_enemys_left = mEnemy.num_enemy - item[i][8];
if (exit_enemys_left <= 0)
{
if ((mPlayer.syn[0].level_done_mode == 0) && (!mNetgame.ima_client)) // only trigger from mode 0 and client can never locally exit
{
mPlayer.syn[0].level_done_mode = 9;
mPlayer.syn[0].level_done_timer = 0;
mPlayer.syn[0].level_done_x = itemf[i][0];
mPlayer.syn[0].level_done_y = itemf[i][1];
mPlayer.syn[0].level_done_player = p;
mPlayer.syn[0].level_done_frame = mLoop.frame_num;
if (!mMain.classic_mode) mPlayer.syn[0].level_done_next_level = 1; // in story mode all exits return to overworld
else mPlayer.syn[0].level_done_next_level = mLevel.get_next_level(mLevel.play_level, 199, 1); // otherwise do next chron level
}
}
}
How The Procedure Progresses
In the main game loop, if 'level_done_mode' is non-zero, 'proc_level_done_mode()' is called.
Otherwise all the objects in the game get moved.
This has the effect of freezing everything when in level done mode.
void mwLoop::move_frame(void)
{
if (mPlayer.syn[0].level_done_mode) proc_level_done_mode();
else
{
mShot.move_eshots();
mShot.move_pshots();
mLift.move_lifts(0);
mPlayer.move_players();
mEnemy.move_enemies();
mItem.move_items();
}
}
In 'proc_level_done_mode()' the timer is decremented and modes are switched
void mwLoop::proc_level_done_mode(void)
{
...
if (--mPlayer.syn[0].level_done_timer <= 0) // time to change to next level_done_mode
{
mPlayer.syn[0].level_done_mode--;
if (mPlayer.syn[0].level_done_mode == 8) mPlayer.syn[0].level_done_timer = 60; // players seek exit
if (mPlayer.syn[0].level_done_mode == 7) mPlayer.syn[0].level_done_timer = 20; // players shrink and rotate into exit
if (mPlayer.syn[0].level_done_mode == 6) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 5) mPlayer.syn[0].level_done_timer = 600; // skippable 15s delay;
if (mPlayer.syn[0].level_done_mode == 4) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 3) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 2) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 1) state[0] = PM_PROGRAM_STATE_NEXT_LEVEL; // load new level
}
}
Mode 9 and 8 - Players Seek Exit
When the level done procedure starts, the level is frozen and all players move to the exit.
void mwLoop::proc_level_done_mode(void)
{
...
if (mPlayer.syn[0].level_done_mode == 9) // pause players and set up exit xyincs
{
for (int p=0; p< NUM_PLAYERS; p++)
if (mPlayer.syn[p].active)
{
mPlayer.syn[p].paused = 5; // set player paused
// get distance between player and exit
float dx = mPlayer.syn[0].level_done_x - mPlayer.syn[p].x;
float dy = mPlayer.syn[0].level_done_y - mPlayer.syn[p].y;
// get move
mPlayer.syn[p].xinc = dx/60;
mPlayer.syn[p].yinc = dy/60;
// set left right direction
if (mPlayer.syn[p].xinc > 0) mPlayer.syn[p].left_right = 1;
if (mPlayer.syn[p].xinc < 0) mPlayer.syn[p].left_right = 0;
}
}
if (mPlayer.syn[0].level_done_mode == 8) // players seek exit
{
for (int p=0; p< NUM_PLAYERS; p++)
if (mPlayer.syn[p].active)
{
mPlayer.syn[p].x += mPlayer.syn[p].xinc;
mPlayer.syn[p].y += mPlayer.syn[p].yinc;
}
}
Mode 7 - Players Shrink and Rotate into Exit
Then the players shrink and rotate into the exit.
void mwLoop::proc_level_done_mode(void)
{
...
if (mPlayer.syn[0].level_done_mode == 7) // shrink and rotate
{
for (int p=0; p< NUM_PLAYERS; p++)
if (mPlayer.syn[p].active)
{
mPlayer.syn[p].draw_scale -= 0.05;
mPlayer.syn[p].draw_rot -= 8;
}
}
Mode 5 - Skippable Delay
Mode 5 is a special case because it can be skipped if all players acknowledge.
When in level_done_mode, all player input is disabled, except to acknowledge.
Here is the test in 'proc_player_input()' to only allow input in level_done_modes 0 and 5.
void mwPlayer::proc_player_input(void)
{
if ((syn[0].level_done_mode == 0) || (syn[0].level_done_mode == 5)) // only allow player input in these modes
In mode 5, players' inputs are modified to be a special 'ack' type game move when they are entered into the game_moves_array.
void mwGameMoves::add_game_move(int frame, int type, int data1, int data2)
{
...
// -----------------------------------------------------------------------------------------------------------------
// if we are in level_done_mode 5, all PM_GAMEMOVE_TYPE_PLAYER_MOVE are converted to PM_GAMEMOVE_TYPE_LEVEL_DONE_ACK
// -----------------------------------------------------------------------------------------------------------------
if ((mPlayer.syn[0].level_done_mode == 5) && (type == PM_GAMEMOVE_TYPE_PLAYER_MOVE) && (data2))
{
if (!has_player_acknowledged(p)) // to prevent multiple acks
add_game_move2(frame, PM_GAMEMOVE_TYPE_LEVEL_DONE_ACK, p, 0);
return; // exit immediately
}
Then there is a function to check if a specific player has acknowledged:
int mwGameMoves::has_player_acknowledged(int p)
{
int start_pos = entry_pos;
int end_pos = start_pos - 1000;
if (end_pos < 0) end_pos = 0;
for (int x=start_pos; x>end_pos; x--) // look back for ack
if ((arr[x][1] == PM_GAMEMOVE_TYPE_LEVEL_DONE_ACK) && (arr[x][2] == p)) return 1;
return 0;
}
And a function to check if all active players have acknowledged:
int mwLoop::have_all_players_acknowledged(void)
{
int ret = 1; // yes by default
for (int p=0; p< NUM_PLAYERS; p++)
{
if (mPlayer.syn[p].active)
{
if (mGameMoves.has_player_acknowledged(p))
{
mPlayer.syn[p].level_done_ack = 1;
}
else
{
mPlayer.syn[p].level_done_ack = 0;
ret = 0;
}
}
}
return ret;
}
In 'process_level_done_mode()' in mode 5, if all players have acknowledged, the timer is set to 0 to immediately skip the delay
This is only done on the server, and then synced back to clients as part of the player struct.
void mwLoop::proc_level_done_mode(void)
{
...
if (mPlayer.syn[0].level_done_mode == 5) // skippable 15s timeout
{
if (!mNetgame.ima_client)
{
if (have_all_players_acknowledged()) mPlayer.syn[0].level_done_timer = 0; // skip
}
}
Also when each player is individually checked for acknowledge the variable 'level_done_ack' is set in the player struct.
This is used to display which players have acknowledged in the level done stats.
void mwScreen::show_player_stat_box(int tx, int y, int p)
{
if (mPlayer.syn[0].level_done_mode == 5)
{
if (!mPlayer.syn[p].level_done_ack)
{
c = mColor.flash_color;
int pay = 16;
al_draw_textf(mFont.pr8, mColor.pc[c], tx+158, y+pay, 0, "press");
al_draw_textf(mFont.pr8, mColor.pc[c], tx+158, y+pay+8, 0, " any");
int tl = mPlayer.syn[0].level_done_timer/40;
if (tl > 9) al_draw_textf(mFont.pr8, mColor.pc[c], tx+154, y+pay+18, 0, " %2d", tl);
else al_draw_textf(mFont.pr8, mColor.pc[c], tx+158, y+pay+18, 0, " %d", tl);
}
else al_draw_textf(mFont.pr8, mColor.pc[15], tx+158, y+20, 0, "ready");
}
If the player has not acknowledged it looks like this with the timer counting down:
If the player has acknowledged it looks like this:
Once all players have acknowledged or timed out we progress to the next level_done_mode.
Mode 1 - Load and start next level
void mwLoop::proc_level_done_mode(void)
{
...
if (--mPlayer.syn[0].level_done_timer <= 0) // time to change to next level_done_mode
{
mPlayer.syn[0].level_done_mode--;
if (mPlayer.syn[0].level_done_mode == 8) mPlayer.syn[0].level_done_timer = 60; // players seek exit
if (mPlayer.syn[0].level_done_mode == 7) mPlayer.syn[0].level_done_timer = 20; // players shrink and rotate into exit
if (mPlayer.syn[0].level_done_mode == 6) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 5) mPlayer.syn[0].level_done_timer = 600; // skippable 15s delay;
if (mPlayer.syn[0].level_done_mode == 4) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 3) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 2) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 1) state[0] = PM_PROGRAM_STATE_NEXT_LEVEL; // load new level
}
}
In 'PM_PROGRAM_STATE_NEXT_LEVEL' we do all the cleanup, resetting, loading and preparing for the next level.
if (state[1] == PM_PROGRAM_STATE_NEXT_LEVEL)
{
mPlayer.syn[0].level_done_mode = 0;
mLevel.play_level = mPlayer.syn[0].level_done_next_level;
mLevel.load_level(mLevel.play_level, 0, 0);
...
state[0] = PM_PROGRAM_STATE_MAIN_GAME_LOOP;
}
}
Other Uses
This procedure is also used for gates on level 1 (overworld). Gates take you immediately to a new level.
They use the exact same procedure, but they jump to mode 3 instantly.
void mwItem::proc_gate_collision(int p, int i)
{
int lev = item[i][6];
if ((mPlayer.syn[p].up) && (status > 0) && (!mNetgame.ima_client) ) // if UP pressed, enter gate (client can never locally enter gate)
{
// immediate next level to gate level
mPlayer.syn[0].level_done_mode = 3;
mPlayer.syn[0].level_done_timer = 0;
mPlayer.syn[0].level_done_next_level = lev;
}
}
This procedure is also used when the server needs to reload a level for whatever reason.
Again its uses the exact same procedure, but starts on mode 3.
void mwNetgame::server_reload(int level)
{
// 1-99 level num
// 0 nothing
// -1 current level
// -2 next level
if ((level != 0) && (mPlayer.syn[0].level_done_mode == 0)) // only trigger from level done mode 0
{
if (level == -2) level = mLevel.get_next_level(mLevel.play_level, 199, 1);
if (level == -1) level = mLevel.play_level;
mPlayer.syn[0].level_done_mode = 3;
mPlayer.syn[0].level_done_timer = 0;
mPlayer.syn[0].level_done_next_level = level;
}
}
End of Game Cutscene
I extended the level done algorithm to make to cutscene at the end of the game.
It is triggered by a special orb on level 64.
void mwItem::proc_orb_collision(int p, int i)
{
if ((item[i][12] == 99) && (!mNetgame.ima_client))
{
mPlayer.syn[0].level_done_mode = 30;
mPlayer.syn[0].level_done_player = p;
mPlayer.syn[0].level_done_frame = mLoop.frame_num;
mPlayer.syn[0].level_done_next_level = 1;
}
}
It goes through level_done_modes 30 to 24, then jumps to mode 6 to join the normal level done procedure.
if (mPlayer.syn[0].level_done_mode == 30) mPlayer.syn[0].level_done_timer = 0; // set up for player move and zoom out
if (mPlayer.syn[0].level_done_mode == 29) mPlayer.syn[0].level_done_timer = 100; // player move and zoom out
if (mPlayer.syn[0].level_done_mode == 28) mPlayer.syn[0].level_done_timer = 0; // set up for rocket move
if (mPlayer.syn[0].level_done_mode == 27) mPlayer.syn[0].level_done_timer = 240; // rocket move
if (mPlayer.syn[0].level_done_mode == 26) mPlayer.syn[0].level_done_timer = 0; // set up for zoom in
if (mPlayer.syn[0].level_done_mode == 25) mPlayer.syn[0].level_done_timer = 100; // zoom in
if (mPlayer.syn[0].level_done_mode == 24) mPlayer.syn[0].level_done_timer = 0; // jump to mode 6
Special Netgame Considerations
A client cannot start the level done procedure locally. The server is always in control.
void mwItem::proc_exit_collision(int p, int i)
{
if ((mPlayer.syn[0].level_done_mode == 0) && (!mNetgame.ima_client)) // only trigger from mode 0 and client can never locally exit
...
void mwItem::proc_gate_collision(int p, int i)
{
if ((mPlayer.syn[p].up) && (status > 0) && (!mNetgame.ima_client) ) // if UP pressed, enter gate (client can never locally enter gate)
...
void mwItem::proc_orb_collision(int p, int i)
{
if ((item[i][12] == 99) && (!mNetgame.ima_client))
This mostly because the client could possibly be rewound by the server and end up not triggering level done.
All of the variables that control level done are synced to the client from the server.
mPlayer.syn[0].level_done_mode
mPlayer.syn[0].level_done_timer
mPlayer.syn[0].level_done_ack
mPlayer.syn[0].level_done_x
mPlayer.syn[0].level_done_y
mPlayer.syn[0].level_done_player
mPlayer.syn[0].level_done_frame;
mPlayer.syn[0].level_done_next_level
The client also does not decrement the timer to get to the next level done mode, or set PM_PROGRAM_STATE_NEXT_LEVEL
The server does that, then syncs the variables to the client.
if (!mNetgame.ima_client)
if (--mPlayer.syn[0].level_done_timer <= 0)
{
mPlayer.syn[0].level_done_mode--;
if (mPlayer.syn[0].level_done_mode == 8) mPlayer.syn[0].level_done_timer = 60; // players seek exit
if (mPlayer.syn[0].level_done_mode == 7) mPlayer.syn[0].level_done_timer = 20; // players shrink and rotate into exit
if (mPlayer.syn[0].level_done_mode == 6) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 5) mPlayer.syn[0].level_done_timer = 600; // skippable 15s delay;
if (mPlayer.syn[0].level_done_mode == 4) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 3) mPlayer.syn[0].level_done_timer = 0;
if (mPlayer.syn[0].level_done_mode == 2) mPlayer.syn[0].level_done_timer = 0; // delay to load next level (was 10, lets try without it as of 20240602)
if (mPlayer.syn[0].level_done_mode == 1)
{
mLog.log_add_prefixed_textf(LOG_OTH_level_done, 0, "[%4d] Level Done Mode:%d - Load lext level\n", frame_num, mPlayer.syn[0].level_done_mode);
state[0] = PM_PROGRAM_STATE_NEXT_LEVEL;
}
}
The client used to also set PM_PROGRAM_STATE_NEXT_LEVEL, but that method was abandoned because of the race conditions it caused.
It would need to get the state from the server to tell it to go to the next level.
So it needed a pause to ensure that it got that info. Still sometimes it would miss and get stuck.
The more robust method I came up with is to have the client automatically call next level when it started getting states from the server for the new level.
void mwNetgame::client_proc_stdf_packet(int i)
{
int slsn = mPacketBuffer.PacketGetInt32(i); // server level sequence num
int sdln = mPacketBuffer.PacketGetInt32(i); // server last level loaded
if (slsn == server_lev_seq_num + 1)
{
mLog.log_add_prefixed_textf(LOG_NET_stdf_packets, -1, "slsn is from next level - setting next level:%d\n\n", sdln);
mPlayer.syn[0].level_done_next_level = sdln;
mLoop.state[0] = PM_PROGRAM_STATE_NEXT_LEVEL;
return;
}
}
Conclusion and Notes
This entire process may seem overly complicated. I have tried a few times to simplify it.
I once spent a few days refactoring the entire procedure. It looked great and was simpler. But it didn't work for netgame.
It is very complicated to get this procedure to work with client and server, when either one can rewind and replay the game state.
As I changed pieces of the simpler system, trying to make it work again, it got more complex, until it was essentially the same algorithm.
Only I had lost an entire day chasing bugs, banging my head against the wall, until I realized why I did it this way in the first place.