Difference between revisions of "Gameplay synchronization"
From MSX Game Library
(→Alternatives) |
|||
(29 intermediate revisions by the same user not shown) | |||
Line 1: | Line 1: | ||
− | Synchronization is the process that | + | Synchronization is the process that ensures a code loop runs at a constant intended speed. |
== Why should I synchronize? == | == Why should I synchronize? == | ||
− | Let's say you want a monster to move from left to right | + | Let's say you want a monster to move from left to right across the screen. |
+ | You create a loop where you increment an X coordinate and update the sprite's position accordingly. | ||
+ | If you test your program, the monster moves at a constant speed... | ||
+ | So why bother with synchronization? | ||
<syntaxhighlight lang="c"> | <syntaxhighlight lang="c"> | ||
void main() | void main() | ||
Line 17: | Line 20: | ||
</syntaxhighlight> | </syntaxhighlight> | ||
− | Let's continue the example: Now you add | + | Let's continue the example: Now you add some code to handle player movement (input reading, basic physics, and collision detection). |
+ | When you run your program again, the first thing you notice is that the monster moves more slowly than before. | ||
+ | And when you start moving your player character, it gets even worse: not only does the monster move even more slowly, but its speed is no longer constant. | ||
Why? | Why? | ||
− | + | Speed is defined as the amount of movement over time. | |
+ | For a constant amount of movement (e.g., +1 pixel to the right), the longer the time taken, the slower the speed. | ||
− | + | If your gameplay loop is not synchronized, the time the CPU needs to execute it will vary, affecting movement speed. | |
+ | Code branching (such as 'if' statements) makes this worse, as execution time becomes not constant (for example, some code may only run if a collision is detected). | ||
− | Synchronizing your code ensures your gameplay loop always executes at the same given speed.<sup>[[#Notes|#1]]</sup> | + | Synchronizing your code ensures that your gameplay loop always executes at the same given speed.<sup>[[#Notes|#1]]</sup> |
== How do I synchronize? == | == How do I synchronize? == | ||
− | Synchronization simply | + | Synchronization simply involves waiting for a signal sent at a constant frequency before executing the next iteration of the gameplay loop. |
+ | The MSX has a system ([[Interrupt handler|interrupts]]) that allows peripheral devices to send signals to the CPU, which the program can then handle. | ||
− | The most commonly used signal for gameplay synchronization is the one sent by the graphics processor (VDP) at the end of | + | The most commonly used signal for gameplay synchronization is the one sent by the graphics processor (VDP) at the end of screen rendering: the "v-blank" (start of the vertical blanking interval). |
+ | This signal is sent every 1/60th of a second on NTSC machines (60 Hz) or every 1/50th of a second on PAL/SECAM machines (50 Hz). | ||
− | If your gameplay loop waits for the v-blank signal, your code will always | + | If your gameplay loop waits for the v-blank signal, your code will always execute at the same time interval, regardless of its actual duration. |
+ | This ensures that all movements occur at a constant speed. | ||
There are several ways to synchronize your gameplay. | There are several ways to synchronize your gameplay. | ||
=== Halt === | === Halt === | ||
− | The Z80 (MSX's CPU) has a special instruction that | + | The Z80 (MSX's CPU) has a special instruction, Halt, that pauses program execution until an [[Interrupt handler|interrupt signal]] occurs. |
− | With MSXgl, | + | As in most contexts, the only interrupt signal the CPU receives is the v-blank signal (triggered by the VDP), you can use the Halt instruction in your gameplay loop to synchronize with v-blank. |
+ | |||
+ | With MSXgl, simply call the <tt>Halt()</tt> function in your gameplay loop (it's good practice to call it first): | ||
<syntaxhighlight lang="c"> | <syntaxhighlight lang="c"> | ||
void main() | void main() | ||
Line 47: | Line 59: | ||
while (1) // Gameplay loop | while (1) // Gameplay loop | ||
{ | { | ||
− | Halt(); // Wait for an | + | Halt(); // Wait for an interrupt signal to occur (hopefully, the v-blank one) |
+ | |||
mPosX++; | mPosX++; | ||
VDP_SetSpritePositionX(0, mPosX); | VDP_SetSpritePositionX(0, mPosX); | ||
Line 56: | Line 69: | ||
</syntaxhighlight> | </syntaxhighlight> | ||
− | === | + | === Interrupt handler === |
− | The problem with | + | The problem with Halt is that it will resume execution for any interrupt signal, not just v-blank. |
+ | This can happen if you use the h-blank feature of the MSX2 (which notifies you when a specific screen line finishes rendering) or if any connected device triggers an interrupt. | ||
− | To | + | To address this, you must check whether the interrupt was caused by the VDP's v-blank signal. |
==== Using BIOS ==== | ==== Using BIOS ==== | ||
− | When the BIOS is active (main-ROM | + | When the BIOS is active (main-ROM selected in memory page #0), an interrupt handler is called whenever an interrupt occurs. |
+ | This handler checks the signal's origin and, if it's from the VDP's v-blank, calls a specific RAM address (called <tt>H.TIMI</tt>). | ||
− | The | + | Your program can "hook" this address to execute its own function. |
+ | The 'hooked' function call is thus synchronized with the v-blank. | ||
− | You have | + | You have two options: |
− | * | + | * Place your entire gameplay loop in this function! This is simple but not very handy for an actual game. |
− | * Use this function to create a blocking function | + | * Use this function to create a blocking function similar to Halt that only waits for the v-blank signal. This is the recommended method. |
− | Example of blocking function: | + | Example of a blocking function: |
<syntaxhighlight lang="c"> | <syntaxhighlight lang="c"> | ||
− | // H_TIMI interrupt hook. Called when v-blank | + | // H_TIMI interrupt hook. Called when v-blank interrupt occured |
void VBlankHook() | void VBlankHook() | ||
{ | { | ||
Line 96: | Line 112: | ||
{ | { | ||
WaitVBlank(); // Wait for an v-blank signal | WaitVBlank(); // Wait for an v-blank signal | ||
+ | |||
mPosX++; | mPosX++; | ||
VDP_SetSpritePositionX(0, mPosX); | VDP_SetSpritePositionX(0, mPosX); | ||
Line 105: | Line 122: | ||
==== Using MSXgl ISR ==== | ==== Using MSXgl ISR ==== | ||
− | When using "ROM_48K_ISR", "ROM_64K_ISR", NEO mapper targets, or any ROM with <tt>InstallRAMISR</tt> | + | When using <tt>"ROM_48K_ISR"</tt>, <tt>"ROM_64K_ISR"</tt>, NEO mapper targets, or any ROM with <tt>InstallRAMISR</tt> set to <tt>"RAM0_ISR"</tt> or <tt>"RAM0_SEGMENT"</tt>, MSXgl's boot program switches memory page #0 to point to a ROM segment or RAM containing a custom interrupt handler (ISR). |
− | This custom ISR is optimized for games | + | This custom ISR is optimized for games and works similarly to the BIOS's ISR for synchronization. |
+ | With MSXgl ISR, there's no need to "hook" a function: the system expects your program to provide a function named <tt>VDP_InterruptHandler()</tt>, which is called every time the v-blank signal is detected. | ||
− | + | The blocking function can now be written as follows: | |
− | |||
− | The | ||
<syntaxhighlight lang="c"> | <syntaxhighlight lang="c"> | ||
− | // | + | // MSXgl ISR callback. Called when v-blank interrupt occured |
void VDP_InterruptHandler() | void VDP_InterruptHandler() | ||
{ | { | ||
Line 135: | Line 151: | ||
{ | { | ||
WaitVBlank(); // Wait for an v-blank signal | WaitVBlank(); // Wait for an v-blank signal | ||
+ | |||
mPosX++; | mPosX++; | ||
VDP_SetSpritePositionX(0, mPosX); | VDP_SetSpritePositionX(0, mPosX); | ||
Line 144: | Line 161: | ||
== How to handle 60 and 50 Hz? == | == How to handle 60 and 50 Hz? == | ||
− | + | The v-blank signal is ideal for synchronization, but its frequency differs between NTSC (60 Hz) and PAL/SECAM (50 Hz) regions. | |
+ | If you synchronize with v-blank, your game will run at different speeds on machines from these regions. | ||
+ | This is a well known issue for European kids back in the days, who played slow Japanese (NTSC) games on their home (PAL/SECAM) MSX. | ||
− | + | Since the program can detect whether the MSX is 50 Hz or 60 Hz, it can adapt to ensure the game runs at the same speed on all machines. | |
There are several ways to do that. | There are several ways to do that. | ||
=== Simple is best === | === Simple is best === | ||
− | The | + | The easiest way to handle the frequency difference is to design your game for 50 Hz and, on 60 Hz machines, skip 1 frame every 6 frames. |
+ | This ensures the same number of gameplay frames per second on all MSX systems. | ||
+ | On 60 Hz machines, 1 frame out of 5 will last slightly longer (which is barely noticeable).<sup>[[#Notes|#2]]</sup> | ||
− | This can be | + | This can be implemented by modifying the WaitVBlank() function: |
<syntaxhighlight lang="c"> | <syntaxhighlight lang="c"> | ||
− | // H_TIMI interrupt hook. Called when v-blank | + | // H_TIMI interrupt hook. Called when v-blank interrupt occured |
void VDP_InterruptHandler() | void VDP_InterruptHandler() | ||
{ | { | ||
Line 168: | Line 189: | ||
g_VBlank = FALSE; // Reset signal flag | g_VBlank = FALSE; // Reset signal flag | ||
if (g_IsNTSC && (g_VBlankCount == 4)) // 5th frame on NTSC machine | if (g_IsNTSC && (g_VBlankCount == 4)) // 5th frame on NTSC machine | ||
− | { | + | { |
while (g_VBlank == FALSE) {} // wait another frame | while (g_VBlank == FALSE) {} // wait another frame | ||
g_VBlank = FALSE; | g_VBlank = FALSE; | ||
g_VBlankCount = 0; | g_VBlankCount = 0; | ||
− | } | + | } |
} | } | ||
</syntaxhighlight> | </syntaxhighlight> | ||
− | + | This method also works well for music playback, allowing the same music (created for 50 Hz) to play at the same speed on both 50 Hz and 60 Hz machines. | |
=== Alternatives === | === Alternatives === | ||
− | Other solutions for managing the | + | Other solutions for managing the 50 Hz/60 Hz difference: |
− | * Do the opposite | + | * Do the opposite: design your game for 60 Hz and, on 50 Hz machines, run the gameplay loop twice every 5 frames. The big problem with this method is that the execution time of 2 frames can greatly exceed the time allowed between two v-blank signals. |
− | * | + | * Use two sets of movement values: one for 50 Hz and one for 60 Hz. This is feasible with [[Fixed-point number|fixed-point arithmetic]] (movement values must be 1.2x greater for 50 Hz to compensate for the frequency difference). You'll also need two sets of music and sound effects, or a format that can adapt (such as VGM).<sup>[[#Notes|#3]]</sup> |
== Notes == | == Notes == | ||
− | * <sup> | + | * <sup>#1</sup> If a gameplay loop's execution time exceeds the synchronization interval, the game will not synchronize correctly. |
− | * <sup> | + | * <sup>#2</sup> To support both 50 Hz and 60 Hz, a gameplay loop must not exceed the worst-case interval: 16.7 ms (60 Hz). |
− | * <sup> | + | * <sup>#3</sup> Some formats, like Arkos Tracker, let you set a tempo for your music (the wait time between beats). If you create music with a tempo that's a multiple of 6, it can play at the same speed on a 50 Hz machine by using the corresponding multiple of 5. |
Latest revision as of 00:17, 18 October 2025
Synchronization is the process that ensures a code loop runs at a constant intended speed.
Contents
Why should I synchronize?
Let's say you want a monster to move from left to right across the screen. You create a loop where you increment an X coordinate and update the sprite's position accordingly. If you test your program, the monster moves at a constant speed... So why bother with synchronization?
void main() { /* Screen and sprites initialization... */ u8 mPosX = 100; // Monster's position X coordinate while (1) // Gameplay loop { mPosX++; VDP_SetSpritePositionX(0, mPosX); } }
Let's continue the example: Now you add some code to handle player movement (input reading, basic physics, and collision detection). When you run your program again, the first thing you notice is that the monster moves more slowly than before. And when you start moving your player character, it gets even worse: not only does the monster move even more slowly, but its speed is no longer constant.
Why?
Speed is defined as the amount of movement over time. For a constant amount of movement (e.g., +1 pixel to the right), the longer the time taken, the slower the speed.
If your gameplay loop is not synchronized, the time the CPU needs to execute it will vary, affecting movement speed. Code branching (such as 'if' statements) makes this worse, as execution time becomes not constant (for example, some code may only run if a collision is detected).
Synchronizing your code ensures that your gameplay loop always executes at the same given speed.#1
How do I synchronize?
Synchronization simply involves waiting for a signal sent at a constant frequency before executing the next iteration of the gameplay loop. The MSX has a system (interrupts) that allows peripheral devices to send signals to the CPU, which the program can then handle.
The most commonly used signal for gameplay synchronization is the one sent by the graphics processor (VDP) at the end of screen rendering: the "v-blank" (start of the vertical blanking interval). This signal is sent every 1/60th of a second on NTSC machines (60 Hz) or every 1/50th of a second on PAL/SECAM machines (50 Hz).
If your gameplay loop waits for the v-blank signal, your code will always execute at the same time interval, regardless of its actual duration. This ensures that all movements occur at a constant speed.
There are several ways to synchronize your gameplay.
Halt
The Z80 (MSX's CPU) has a special instruction, Halt, that pauses program execution until an interrupt signal occurs. As in most contexts, the only interrupt signal the CPU receives is the v-blank signal (triggered by the VDP), you can use the Halt instruction in your gameplay loop to synchronize with v-blank.
With MSXgl, simply call the Halt() function in your gameplay loop (it's good practice to call it first):
void main() { /* Screen and sprites initialization... */ u8 mPosX = 100; // Monster's position X coordinate while (1) // Gameplay loop { Halt(); // Wait for an interrupt signal to occur (hopefully, the v-blank one) mPosX++; VDP_SetSpritePositionX(0, mPosX); /* Player handling… */ } }
Interrupt handler
The problem with Halt is that it will resume execution for any interrupt signal, not just v-blank. This can happen if you use the h-blank feature of the MSX2 (which notifies you when a specific screen line finishes rendering) or if any connected device triggers an interrupt.
To address this, you must check whether the interrupt was caused by the VDP's v-blank signal.
Using BIOS
When the BIOS is active (main-ROM selected in memory page #0), an interrupt handler is called whenever an interrupt occurs. This handler checks the signal's origin and, if it's from the VDP's v-blank, calls a specific RAM address (called H.TIMI).
Your program can "hook" this address to execute its own function. The 'hooked' function call is thus synchronized with the v-blank.
You have two options:
- Place your entire gameplay loop in this function! This is simple but not very handy for an actual game.
- Use this function to create a blocking function similar to Halt that only waits for the v-blank signal. This is the recommended method.
Example of a blocking function:
// H_TIMI interrupt hook. Called when v-blank interrupt occured void VBlankHook() { g_VBlank = TRUE; // Set signal flag } // Wait for v-blank period void WaitVBlank() { while (g_VBlank == FALSE) {} // g_VBlank become TRUE when v-blank signal g_VBlank = FALSE; // Reset signal flag } // Program entry void main() { /* Screen and sprites initialization... */ Bios_SetHookCallback(H_TIMI, VBlankHook); // Register our function u8 mPosX = 100; // Monster's position X coordinate while (1) // Gameplay loop { WaitVBlank(); // Wait for an v-blank signal mPosX++; VDP_SetSpritePositionX(0, mPosX); /* Player handling… */ } }
Using MSXgl ISR
When using "ROM_48K_ISR", "ROM_64K_ISR", NEO mapper targets, or any ROM with InstallRAMISR set to "RAM0_ISR" or "RAM0_SEGMENT", MSXgl's boot program switches memory page #0 to point to a ROM segment or RAM containing a custom interrupt handler (ISR).
This custom ISR is optimized for games and works similarly to the BIOS's ISR for synchronization. With MSXgl ISR, there's no need to "hook" a function: the system expects your program to provide a function named VDP_InterruptHandler(), which is called every time the v-blank signal is detected.
The blocking function can now be written as follows:
// MSXgl ISR callback. Called when v-blank interrupt occured void VDP_InterruptHandler() { g_VBlank = TRUE; // Set signal flag } // Wait for v-blank period void WaitVBlank() { while (g_VBlank == FALSE) {} // g_VBlank become TRUE when v-blank signal g_VBlank = FALSE; // Reset signal flag } // Program entry void main() { /* Screen and sprites initialization... */ u8 mPosX = 100; // Monster's position X coordinate while (1) // Gameplay loop { WaitVBlank(); // Wait for an v-blank signal mPosX++; VDP_SetSpritePositionX(0, mPosX); /* Player handling… */ } }
How to handle 60 and 50 Hz?
The v-blank signal is ideal for synchronization, but its frequency differs between NTSC (60 Hz) and PAL/SECAM (50 Hz) regions. If you synchronize with v-blank, your game will run at different speeds on machines from these regions. This is a well known issue for European kids back in the days, who played slow Japanese (NTSC) games on their home (PAL/SECAM) MSX.
Since the program can detect whether the MSX is 50 Hz or 60 Hz, it can adapt to ensure the game runs at the same speed on all machines.
There are several ways to do that.
Simple is best
The easiest way to handle the frequency difference is to design your game for 50 Hz and, on 60 Hz machines, skip 1 frame every 6 frames. This ensures the same number of gameplay frames per second on all MSX systems. On 60 Hz machines, 1 frame out of 5 will last slightly longer (which is barely noticeable).#2
This can be implemented by modifying the WaitVBlank() function:
// H_TIMI interrupt hook. Called when v-blank interrupt occured void VDP_InterruptHandler() { g_VBlank = TRUE; // Set signal flag g_VBlankCount++; } // Wait for v-blank period void WaitVBlank() { while (g_VBlank == FALSE) {} // g_VBlank become TRUE when v-blank signal g_VBlank = FALSE; // Reset signal flag if (g_IsNTSC && (g_VBlankCount == 4)) // 5th frame on NTSC machine { while (g_VBlank == FALSE) {} // wait another frame g_VBlank = FALSE; g_VBlankCount = 0; } }
This method also works well for music playback, allowing the same music (created for 50 Hz) to play at the same speed on both 50 Hz and 60 Hz machines.
Alternatives
Other solutions for managing the 50 Hz/60 Hz difference:
- Do the opposite: design your game for 60 Hz and, on 50 Hz machines, run the gameplay loop twice every 5 frames. The big problem with this method is that the execution time of 2 frames can greatly exceed the time allowed between two v-blank signals.
- Use two sets of movement values: one for 50 Hz and one for 60 Hz. This is feasible with fixed-point arithmetic (movement values must be 1.2x greater for 50 Hz to compensate for the frequency difference). You'll also need two sets of music and sound effects, or a format that can adapt (such as VGM).#3
Notes
- #1 If a gameplay loop's execution time exceeds the synchronization interval, the game will not synchronize correctly.
- #2 To support both 50 Hz and 60 Hz, a gameplay loop must not exceed the worst-case interval: 16.7 ms (60 Hz).
- #3 Some formats, like Arkos Tracker, let you set a tempo for your music (the wait time between beats). If you create music with a tempo that's a multiple of 6, it can play at the same speed on a 50 Hz machine by using the corresponding multiple of 5.