Skip to content

Step by Step New Minigame Tutorial

Buu342 edited this page Feb 7, 2023 · 24 revisions

Before you start reading, I'd like to mention something important.

DoomWare's "official" 100 minigames reside in MAP01, which is Doom in Hexen format. I have created a MAP02, which is in UDMF format, and I highly recommend that people willing to contribute to DoomWare make their minigames for that map instead, because not only is it a clean slate, you won't need to worry about having to fight against the many limits that I ran into due to the Doom in Hexen format.

That is all. Good luck, and I hope you have a fun time modding DoomWare!

Table of Contents

Prerequisite knowledge
How a minigame works
Steps to take before making a new minigame
Basic minigame script
Timers
Teleporting Players
Winning and Losing
Observers
Morphing Players
Wacky Mods
Cleaning up after a minigame ends
Other Useful Notes
10 Commandments for good minigame design
What about adding more tie breakers?

Prerequisite knowledge

  • As explained before, it is expected that you have a strong understanding of ACS, and preferably DECORATE if you intend on doing something more complex.
  • Familiarize yourself with the list of macros and global variables at the very top of the code. These will come in handy.
  • Having the DoomWare API open on a separate page helps.

How a minigame works

DoomWare uses three main "threads" to handle the game logic. These threads are essentially three ACS scripts which are always running in the background:

  • A purely CLIENTSIDE script, which handles the actual drawing of HUD elements. You will probably never need to touch these.
  • A CLIENT script, which runs an individual copy for each player, where said player is the activator of the script. These scripts are best used for things which are going to affect a player individually, as you have easy access to the PlayerNumber() function.
  • A SERVER script, which has no activator and typically handles things which affect all players and/or the map.

When a minigame starts, every player in the game is placed in a global array called player_midround[]. Players have two ways of winning minigames, either they:

  1. Are moved from the player_midround[] array to the player_wonround[].
  2. They're inside the player_midround[] when the global variable round_winifmid is set to true.

Similarly, a player will lose the game if they:

  1. Are inside the player_lostround[] array.
  2. They're inside the player_midround[] when the global variable round_winifmid is set to false (which it is by default).

In order to make your life easier, the functions Player_Win() and Player_Lose() exist to move people between the arrays. For more information on their arguments, check the DoomWare API page.

⚠️ Remember, every player is running their own copy of the CLIENT script. This means that, if you need to your CLIENT script to call another, make sure you use ACS_ExecuteAlways! On the SERVER script, you can use ACS_Execute like normal.

Steps to take before making a new minigame

The number of minigames currently in DoomWare is based on the hardcoded value NUMBEROFGAMES. Simply increase this number by 1 and, preferably, change the value of DEBUG_GAME from -1 to the number of your new minigame (So if you changed NUMBEROFGAMES from 100 to 101, your minigame will be using the number 101) so that your minigame will run every round when you playtest.

Now, create two new named scripts called DoomWare_Client_Minigame# and DoomWare_Server_Minigame# (replacing # with your minigame number). You are ready to start making your minigame!

Basic minigame script

The following is a basic CLIENT and SERVER minigame script:

Script "DoomWare_Client_Minigame101" (void)
{
    // Tell the player what to do
    minigame_instruction1[PlayerNumber()] = "Do something!";

    // Wait until the minigame is over
    while (game_status == STATUS_MINIGAME)
        delay(1);
}

Script "DoomWare_Server_Minigame101" (void)
{
    // This HUDMessage is for logging the minigame in the console.
    HUDMessage(s:"Do something!"; HUDMSG_LOG, MSGID_CONSOLE, CR_BLACK, 2.0, 2.0, 0);

    // Set the music
    SetMusic("D_MUSIC");

    // Minigame lasts for a second
    delay(SECOND);
}

Taking a quick look at the code, you should understand that the SERVER script is what actually dictates the length of the minigame, while the CLIENT script is to keep running in a loop until the minigame finishes on the SERVER. You might've also noticed that SERVER minigames have an extra HUDMessage at the start. This extra HUDMessage is not necessary, all it simply does is print the current minigame to the console logs (in order to make it easier to tell what happened when server administrators are reading the logs):

image

The actual printing of the minigame instructions in the center of the player's screen are handled by the minigame_instruction1[] array. If you wish to print more instructions, you can use the minigame_instruction2[] and minigame_instruction3[] arrays respectively. The former is typically used for extra minigame specific controls, while the latter is only used in the swapping boxes minigame for the onscreen timer (frankly, it's unlikely you'll ever need to use it).

To make instructions easier to read, I color code button prompts like so:

Button Color String
Shoot Green "\cdShoot\cg"
Use Gray "\ccUse\cg"
Jump Blue "\chJump\cg"
Strafe Yellow "\ckStrafe\cg"

A full list of color codes is available on the ZDoom Wiki.

💡 Because the minigame_instruction#[] variables are arrays, it means you can make the instructions on the center of the screen specific per player.
💡 You can use the minigame_wincondition# global variables to synchronize extra logic between the SERVER and CLIENT script.

Timers

In the example SERVER script in the last section, the duration of the minigame itself was dictated by the single delay. This is fine, but unless a very specific set of sequences happen in your minigame (for instance, in the The Floor is Lava minigame, the map goes through a very specific sequence where: the lights lower, the floor turns to lava for a second, and then the lights raise back up) it shouldn't be used. DoomWare instead provides a script called "DoomWare_Server_GameWait", which is better because:

  • It allows the minigame timer to display on the player's HUD during the game, letting them know exactly how much time they have left to complete the objective.
  • It allows the minigame to finish early if all players either won or lost, meaning players won't need to wait for the full length of the minigame timer.

The script can be called with either ACS_NamedExecuteWait or ACS_NamedExecute. The former will pause the execution of the minigame SERVER thread until the timer ends (or everyone dies/finishes):

Script "DoomWare_Server_Minigame101" (void)
{
    // Stuff happens here

    // Start a timer
    ACS_NamedExecuteWait("DoomWare_Server_GameWait", 0, SECOND);

    // Any code below will only execute after the SECOND passes, similar to a Delay()
}

While the latter will let the minigame script continue while the timer "thread" runs in the background. This allows for extra logic to be added as the game executes, but requires a loop like this:

Script "DoomWare_Server_Minigame101" (void)
{
    // Stuff happens here

    // Start a timer, but don't wait this time!
    ACS_NamedExecute("DoomWare_Server_GameWait", 0, SECOND);

    // Start a while loop to run extra logic every tick
    do
    {
        // Extra minigame logic here

        // Delay one tick to prevent a runaway script
        delay(1);
    }
    while (minigame_timer != TIMER_OFF);

    // Any code here will execute after the minigame timer ends
}

💡 You can prevent premature round termination by setting round_noforceend to true.
💡 The game_speed variable stores the current game speed, being a value between 0 (slow) and 4 (fast). You can use this to make minigames durations vary depending on the game speed, for example:

ACS_NamedExecuteWait("DoomWare_Server_GameWait", 0, SECOND*10 - game_speed*SECOND);

Will make the game last 10 second by default, but 6 seconds at the fastest game speed.

⚠️ Remember that ACS uses fixed point numbers! So SECOND*1.5 actually means SECOND*98304.

Teleporting Players

When you want your minigame to occur in a special arena, you'll need to teleport players to it. This sounds like it should just be a matter of calling a function like Thing_Move or TeleportInSector, but the reality is that ZDoom/Zandronum loves to make life difficult by introducing random problems (Like choosing to arbitrarily not teleport a single player to the arena). As a result, DoomWare provides a script called "DoomWare_Server_TeleportPlayers" which allows you to teleport players to an arena without much trouble (as it has plenty of safety checks in place to make sure people actually get to the arena).

There are two main methods that DoomWare uses to teleport players to the arena:

  1. Teleporting players to a sector.
  2. Teleporting players to individual TID's.

Method one requires that the destination have the same size as the lobby (a 640x640 square), but only requires a single Teleport Destination TID placed in the center of the square. You should avoid detailing the floor of this square as you can run the risk of players potentially getting stuck when teleported in. The second method allows for a lot more flexibility, but requires that your destination have 16 Teleport Destination's. Each teleport destination should have a different TID, and their TID's should be consecutive (So for instance, the first Teleport Destination can have a TID of 70, the next one should have 71, etc... until the last one which should have 85).

The "DoomWare_Server_TeleportPlayers" script takes three arguments:

  1. int - The Thing ID of the teleport destination. If using the second teleport method, it's the Thing ID of the first destination.
  2. bool - Use telesector method?
  3. bool - Create teleport effect?

⚠️ The direction which the teleport destination TID is facing is very important as the player's view will be modified to face the same direction as the destination!
⚠️ When players are teleported using method two, they will be teleported to a random TID. Don't expect PlayerNumber() with value 0 to end up on the first destination TID.

You can also use the "DoomWare_Server_TeleportSingle" script if you ever need to teleport a single player to a very specific spot, but this is only used in Tie Breaker games.

Once your minigame is finished, you can call the script "DoomWare_Server_ReturnPlayers" to move players back to the main arena. Its single argument is whether to create the teleport effect.

Here is an example script demonstrating how to use the teleport functions in a minigame:

Script "DoomWare_Server_Minigame101" (void)
{
    // Move all the players to the arena, using the individual TID method and with a teleport effect
    ACS_NamedExecute("DoomWare_Server_TeleportPlayers", 0, 70, false, true);

    // Start a three second timer
    ACS_NamedExecuteWait("DoomWare_Server_GameWait", 0, SECOND*3);

    // Return players back to the main arena
    ACS_NamedExecute("DoomWare_Server_ReturnPlayers", 0, true);
}

⚠️ Before players are teleported to the arena, their positions in the lobby are stored in the player_position[] array. When you call either "DoomWare_Server_TeleportPlayers" or "DoomWare_Server_ReturnPlayers", their positions are stored before teleporting them to the arena. Once the minigame finishes, they're teleported back to that specific position (which should be where they last were before the game started). Players will not be teleported if their position is invalidated, which will only typically happen when they die (because it wouldn't make sense to teleport them back to the lobby, given that they respawn there). If you need to have more control over these positions (for instance, a minigame where players will be teleported out of the arena if they complete a specific task, like the Enter The Door minigame), you can use the Player_SavePosition() and Player_InvalidatePosition() functions to assist you.

Winning and Losing

As mentioned in a previous section, players will win if they're in the player_midround[] array and the global variable round_winifmid is set to true, or if they're manually placed in the player_wonround[] array by calling the function Player_Win(). However, because this is a function, there's no way to call it in the map or in DECORATE. Due to this, you must have a separate script that calls Player_Win() internally to allow players to win when they interact with the map by activating linedefs, entering sectors, killing monsters, etc... Same goes for Player_Lose(). In MAP01, you can use Script 12 (it needs to be a number since linedef specials don't support named scripts).

Outside of the aforementioned Player_Win() and Player_Lose() functions, there are other methods to handle win states. There exists a DECORATE actor called WonRoundItem, which you can give to players. To check whether players have obtained this item, you can use the existing Check_WonRoundItem() function which iterates through all players and automatically calls Player_Win() if they're holding the item. This should be used in conjunction with a while loop like this:

Script "DoomWare_Server_Minigame101" (void)
{
    // Start a timer (But don't wait!)
    ACS_NamedExecute("DoomWare_Server_GameWait", 0, SECOND*4-(10*game_speed));

    // Check for winners
    do
    {
        Check_WonRoundItem();
        delay(1);
    }
    while (minigame_timer != TIMER_OFF);
}

💡 There is also a Check_FailRoundItem() function, which should hopefully be obvious regarding how it works...

In deathmatch games, once there's a single player left, the minigame can be considered finished (because there's no one left to kill/get killed by). So if you want to stop the minigame early, you can use the Check_LMS() function:

Script "DoomWare_Server_Minigame101" (void)
{
    round_winifmid = true;     // NEEDED!!! Since this is a deathmatch game, a player wins if they don't die.
    minigame_fragpoints = true // Gives points with frags

    // Start a timer (But don't wait!)
    ACS_NamedExecute("DoomWare_Server_GameWait", 0, SECOND*4-(10*game_speed));

    // Stop the minigame when there's one player left
    do
    {
        Check_LMS();
        delay(1);
    }
    while (minigame_timer != TIMER_OFF);
}

If for some reason you want players to win if they die, then set the global variable round_winifsuicide to true.

Observers

If you want people to be able to observe the minigame after they died, you can do so by placing an Aiming Camera actor in the map with a specific TID. Afterwards, call the Add_Observer() function, supplying the TID of this camera as the first argument of the function. If you want more cameras, place more actors and make more consecutive calls of the Add_Observer() function. The order of the cameras will be in the order of the Add_Observer() function calls.

⚠️ DoomWare supports 8 cameras per minigame. If you need more cameras than that, then increment the value of the NUMOBSERVERS macro.

If your minigame requires that players watch through a camera at all times (like sidescrolling minigames), you can forcefully make people view the camera by calling the Force_Observe() function, with the first argument being the PlayerNumber() of the player to look at the camera. For this function to work, the camera needs to be added to the observers list beforehand!

⚠️ Because solo play disables the observer camera system (since there's no other players to observe once a player loses), minigames that require players to look through a camera to play must have the round_forceobservers global variable set to true. If this is missing, the camera will not be used during solo play, which will probably break the minigame.

Morphing Players

Much like teleporting players, morphing players seems like it should be pretty simple, but once again ZDoom/Zandronum loves to make life difficult. Therefore, the Player_Morph() function exists, where the first argument is the PlayerNumber() of the player to morph, and the second argument a string with the name of the Actor to morph into (which you should have created in DECORATE beforehand). Because this is a function, it can't have any delays inside it, so I recommend calling it in a loop to ensure the player actually morphed (it's not a script because those can't have string arguments). Once a minigame finished, it's recommended to call UnMorphActor() to turn them back to normal.

Here is an example script:

Script "DoomWare_Client_Minigame101" (void) 
{
    minigame_instruction1[PlayerNumber()] = "Do something";

    // Morph the player into something
    str class = "MorphActorName";
    while (StrCmp(GetActorClass(TID_PLAYER+PlayerNumber()), class) != 0)
    {
        Player_Morph(PlayerNumber(), class);
        delay(1);
    }

    // Wait until the game's over
    while (game_status == STATUS_MINIGAME)
        delay(1);
		
    // Unmorph the player back
    UnMorphActor(TID_PLAYER+PlayerNumber(), true);
}

Wacky Mods

Sometimes, wacky modifiers can have an impact on how a game works. The global variable game_wackymod lets you check if there's an active wackymod, with its return value being one of the WACKYMOD_# macro number values. With that, you should be able to accommodate for any potential game breaking bugs caused by wacky mods (which is incredibly unlikely, but it's there just in case).

The doomware_wackymodsmonsters CVar allows you to have monsters with some of the Wacky mods. To have monsters with these effects, simply have 3 variations of your monster (with the word _Flight, _Rage, and _Spread appended to their names), and spawn them with the SpawnSpotForcedEx or SpawnSpotFacingForcedEx functions (which behave exactly like their non Ex counterparts). So for instance, to have a Pain Elemental which is affected by Wacky Mods, create 3 DECORATE monsters called PainElemental_Flight, PainElemental_Rage, and PainElemental_Spread. In the SpawnSpotForcedEx function, you only need to put "PainElemental" as the class name, the function will automatically spawn the correct one.

Cleaning up after a minigame ends

For the most part, any global variable that you changed does not need to be reset, as they're cleared once the minigame ends. For entities which you created, anything with a Thing ID value of TID_REMOVE will be automatically removed after the minigame finishes. Player inventories, health, and invulnerability state will be reset as well. If you modified the map in some way, you must manually put it back to its initial state. Observers are also automatically cleared, so there is no need to manually call the "DoomWare_Server_ClearObservers" script.

⚠️ It is important that you test your minigame multiple times to ensure it works when played multiple times in a row. This is because server owners can enable the doomware_duplicates CVar, which results in the possibility of duplicate games.
⚠️ Also, if you changed the value of DEBUG_GAME, make sure you change it back to -1 before shipping your final product!

Other Useful Notes

  • Global variables with the comment (Internal) next to them should not be modified as they're used by DoomWare's engine! Treat these as READ ONLY.
  • CLIENT minigame scripts are called at least one tick after the SERVER minigame scripts, so you can trust that any logic in the beginning of the SERVER minigame script will be executed first.
  • Be careful with TID's and Sector ID's. Because all the minigames occur in one map, there's a lot of TID and Sector Tags's that are currently being used. Typically, if you press the "New" or "Unused" button, that ID should be safe to use (though I typically trust "New" more). If you're using Doom Builder, you can use the Find and Replace function (F3 by default) to check if the TID or Sector Tag is being used. Also, remember that teleport method two requires 16 consecutive TID's.
  • Also related to above, some actions in Doom Builder can only use ID's as high as 255. For these cases, you'll need to either swap out the ID of something else in the map (be careful to change the ACS as well to ensure you don't break anything), or you use the ACS equivalent of that function (which should support values larger than 8-bits).
  • Upon spawning/respawning, all players are assigned a TID with value TID_PLAYER plus their PlayerNumber(), so the first player will have TID with a value of TID_PLAYER, the second will have TID_PLAYER+1, etc...
  • If you need to freeze players totally, use the Player_FreezeTotally() function as opposed to SetPlayerProperty to ensure you don't break freezing when players are using the DoomWare Menu (among other things). Similarly, use Player_UnFreezeTotally to unfreeze them.
  • If you want your minigame to award 1ups for frags, set the global variable minigame_fragpoints to true.
  • Because DoomWare features Solo play, deathmatch games are a bit awkward since they're not really playable. You can prevent these games from being available during solo play by adding them to the ban list string in the BANNEDSOLOGAMES macro.
  • The hats that appear above players (like who's in the lead) can sometimes block people's views. You can turn them invisible by setting the global variable minigame_invisiblehats to true.
  • You can use the player_answer1[] and player_answer2[][] global arrays to store data player data for minigames, which you can use to sync values between the SERVER and CLIENT scripts.
  • If you want to display a fake weapon sprite, you can do so by changing the value of the string array player_viewsprite[] to the lump name of the weapon sprite.

10 Commandments for good minigame design

These are Buu342's rules when making minigames for DoomWare. You don't need to follow them, but I would recommend you trust my personal experience with playtesting this WAD with many people over the course of its 5+ year development.

  1. Ensure your games work for both OpenGL and Software renderers. Avoid minigames where a person is required to look straight up or down.
  2. Everyone should have an equal chance at winning a minigame.
  3. Avoid minigames where only one person can win, as this will make it harder for people to catch up if they're behind on points.
  4. Similar to above, avoid minigames which split players into teams, because it puts players in the awkward position of being forced to help the player in first place.
  5. Minigame instructions should be 7 words or less. If a special key bind is required, make that part of the secondary instruction and color code the bind.
  6. Avoid requiring special binds (such as crouching or secondary fire) as most players don't have these bound. Stick to games which require movement keys, shoot, jump, or use.
  7. Avoid multi-instruction minigames. Make the game only require one very specific action from the player to win. Goes hand in hand with commandment 5.
  8. Avoid games which require pre-requisite knowledge to beat. Everything should be as self-contained as possible.
  9. Color blind people exist! As do deaf people! If your game requires colors, at least try to make it possible for people with red-green color blindness (Deuteranopia and Deuteranomaly) which affects 8% of males. Use this tool to help you out. For minigames with sound cues, make sure some visual cue exists as well (also side note, many competitive players don't play with music).
  10. Personal preference, but minigames which allow you to see and interact with other playes (as opposed to just looking at a GUI) are much more interesting.

What about adding more tie breakers?

If you've read this Wiki page in its entirety, you should have a pretty good grasp on what to do to make more tie breakers. I recommend you look at how some of the pre-existing ones are made for more in-depth information.

Clone this wiki locally