What is a game tick?
That can differ from game to game, but usually it is one iteration of all game logic and the rasterization of the current frame.
So what needs to happen in the Astro-Ducks game?
Well, we got a ship that the player needs to be able to control. We also have a set of enemies that we need to control. The player and the enemies can collide with eachother so we need to take care of that. It would'n be much of a game if the player could'nt do anything more than move, so we are giving the player the ability to shoot projectiles as well. These projectiles can also collide with the enemies - another thing we need to take care of.
We can try to boil this down to a list of things that need to happen:
Something we also need to mention when talking about the game tick is time based movement. As hardware can be very different, we need a way for our game to run at the same speed no matter what system your on. When we update positions of objects in our game world, they need to be updated according to how much time has passed since the last tick.
Simplistic approach
The simplistic approach would be to do everything for each object in the Tick function: Update position of object, do eventual collisions check and finally render the object. This, however, is a bad idea(14.6.1). There are a couple of reasons why this is bad, mainly:
Risk of creating order of operation dependency
In a game, it is likely that the game objects might depend on eachother. If one tried to do everything for an object in its Tick function, the order of updating the objects can matter. This is bad and usually leads to bugs that are hard to resolve. Imagine for example that we had this approach in the Astro-Ducks and we let the player object handle collision testing of its projectiles against the ducks. In this scenario, we would have made it so that it matters if the ducks positions are updated (Ticked) before the player is Ticked.
If we were to introduce new enemies, they would all have to be updated before or after the player depending on what we consider to be correct. Now imagine a large, complex game with many more game objects, this dependency of order will be impossible to get loose from and could start dictating how the code should be written. You do not want to end up here.
Performance
It is usually easier to write cache-friendly code if you can work on a large set of data for multiple objects than if your doing one part accessing the graphical representation, then accessing the part for the physics/collision detection, then another one accessing audio resources etc. Making interleaved calls to each service we need (collision check, game logic update and rendering) makes it hard to write cache-friendly code where all the needed data is close-by in memory. It will also be harder to keep shared calculations for each service such as the rendering and collision system - possibly forcing recalculations for each call to the Tick function.
A better approach
There are more reasons why you should avoid the simplistic approach, mentioned in the bad idea(14.6.1). But lets carry on and focus on what we want to build instead. For our game, we want 3 separate steps for each game frame:
As we can see in the main loop, we have these distinct steps for each game frame we produce. The first thing we do is calculate positions of ducks and player of the next frame in our CollisionTick calls (one for ducks, one for player(s)). This is done so we can perform a collision test, which you can read more about in the Collision Managment page(still writing this page...).
In the collisionEngine.Tick, we will ask the objects to take some action if they collided (bounce back if ducks collided for example).
Next, we perform the game logic update - here we will update our positions, rotations and other internal states. This happens in the Tick calls for the player(s), the ducks and the particle system.
We also update the HUD. So far, the HUD does'nt have its own Tick function updating the internal state (this will probably be updated later as its a bit messy to have it in the main game loop). So we update the player(s) health and score as well as the amount of time left to clear out all the ducks
We also check if the player has cleared the level (ducks.empty()). If so, we respawn a set of new ducks. If we have two players and one of the players died during this round, we bring that player back to life.
If the player(s) are both dead or if time has run out, we display the "game over" sign and make it possible for the player to restart the game (if(PlayersDead(players) || (minutesLeft == 0 && secondsLeft == 0))
while(glfwGetKey(window, GLFW_KEY_ESCAPE ) != GLFW_PRESS && glfwWindowShouldClose(window) == 0) { // Time difference between frames double currentTime = glfwGetTime(); float deltaTime = float(currentTime - lastTime); Duck::CollisionTick(ducks, deltaTime); Player::CollisionTick(players, deltaTime); collisionEngine.Tick(); players[0]->Tick(deltaTime); if(players.size() == 2) { players[1]->Tick(deltaTime); } Duck::Tick(ducks, deltaTime); ParticleSystem::TickParticleSystems(deltaTime); gameHUD.UpdatePlayerHealth(players[0]->GetHealth()); gameHUD.UpdatePlayerScore(players[0]->GetScore()); if(players.size() == 2) { gameHUD.UpdatePlayerTwoHealth(players[1]->GetHealth()); gameHUD.UpdatePlayerTwoScore(players[1]->GetScore()); } double timePassed = gameTimeToClearLevel - (currentTime - timeStartGame); int minutesLeft = static_cast<int>(timePassed / 60); int secondsLeft = static_cast<int>(timePassed - static_cast<float>(minutesLeft) * 60.0f); if(ShouldUpdateTime(players, minutesLeft, secondsLeft)) { gameHUD.UpdateTimeLeft(minutesLeft, secondsLeft); } gameHUD.PrepareHUDGUIForRender(); HandleKeyInput(window, inputState, deltaTime, players[0].get(), players.size() == 2 ? players[1].get() : NULL, restartGame); gameScene.RenderScene(); glfwPollEvents(); glfwSwapBuffers(window); if(ducks.empty()) { // If one player died, bring him back but give him only 2 lifes at start and reduce his score by 200 for(auto &player : players) { if(player->GetHealth() == 0 ) { player->ResetHealth(2); player->DecreaseScore(200); } } nrDucksToCreate++; SpawnDucks(ducks, nrDucksToCreate, players[0].get()); timeStartGame = glfwGetTime(); } if(PlayersDead(players) || (minutesLeft == 0 && secondsLeft == 0)) { gameHUD.ShowGameOverSign(true); if(restartGame) { ResetGame(players, ducks, timeStartGame, nrDucksToCreate); gameHUD.ShowGameOverSign(false); restartGame = false; } } lastTime = currentTime; }
The while-loop is designed to run until the user hits the Escape button - that is when the game will end. In a future version of the game, this will take us back to the main-menu of the game, but since we dont have that yet this will terminate the game
As mentioned earlier, we need to do time-based movements in our game. That is why we are fetching a timestamp from glfwGetTime. We then calculate the difference from the last time we took a timestamp (lastTime). The diff, deltaTime, is what we will pass on to all of our Tick functions so they can use it to update things with respect to the amount of time that has passed.
We can also see the part that is responsible for the rendering in Astro Ducks here - gameScene.RenderScene(). the gameScene object is responsible for rendering everything that you can see in the scene. This part will be covered in the Rendering section of this blog
Having a nice main game loop makes it easy to understand the structure of your game. So you want to try to keep things at high abstraction level here, making it as simple as possible to get an overview of what your game does in each iteration.
The observant reader will also notice that players is an array, and not a single instance - that is because this game supports local coop.