MicroWorks

MicroWorks

Not enough ratings
Modding Pt.3: Custom Scenes
By noam 2000
MicroWorks' final 1.11 update introduces extended modding capabilities, including custom microgames, boss stages, and scenes.

In part 3 of this tutorial series, we'll be learning how to create a custom level we can load from the main menu, and how to attach scripts to that level to create our own gamemode.

1. Custom Microgames
2. Custom Boss Stages
3. Custom Scenes >> YOU ARE HERE <<

The guides are designed in a sequential order, and it is recommended to follow them in order, even if you are only interested in a specific one.

This is not a guide for beginners. You will be required to have some scripting knowledge, particularly with the Lua scripting language.

For any questions, ask us! Either in the Steam forums or on Discord.[discord.gg]
   
Award
Favorite
Favorited
Unfavorite
Before We Begin
This guide is a direct continuation to Modding Pt.1: Custom Microgames and Modding Pt.2: Custom Boss Stages. It is strongly recommended that you read them first, as we will not be repeating what we learned in previous parts.

In this part, we will learn how to make a custom level that we created be loadable from within the Extras -> Scenes menu in the main menu, kind of like the Sandbox. We will also learn how to attach scripts to that level to run our own exclusive gamemodes.

As an example, we will make a simple deathmatch scene called "Kai Wars", where players battle each other and gain score for frags. When a player reaches the score threshold, they will win and the scene will restart. We'll also scatter BONUS DUCKS because we just need to have ducks in this tutorial too.

The sources and assets for this scene can be found in this Google Drive folder.[drive.google.com]

Let's get to it!
Prepare Your Level
In the previous part, we learned how to create custom levels with MicroKit. For this scene, I've prepared a simple little playground where players will battle each other:



You can create your own level, or get this one from the Google Drive folder.

Once you've prepared your geometry, import it into MicroKit and create your level.



Looking good! Let's fill up some entities, like the spawn points and weapon spawners.



Ready? Let's save the level into:

StreamingAssets/CustomMaps/KaiWars

And that's it! Our level is ready to be played. If you want to skip over the level creation part and just want to learn how to create the scene, you can always grab the final level from the Google Drive folder.

Now that we have our level, let's learn how to create a scene that will load into this level.
Scene: Setup
You probably have an idea of how this is gonna go. Let's navigate to StreamingAssets, and create a new "Scenes" folder.

Inside that folder, we'll create a .scn file, which will be the scene descriptor. Like .mcg and .bos, .scn is a json file in disguise, but it might have some slightly different rules compared to those other two descriptors. It looks something like this:

{ "SceneName": "KaiWars_SceneName", "SceneDescription": "KaiWars_SceneDescription", "SceneGamemode": "Scorematch", "SceneThumbnail": "\\KaiWars\\Textures\\KaiWars.png", "SceneOnlineModes": [ "Public", "FriendsOnly" ], "SceneLocalization": { "en": "\\KaiWars\\Locale\\en.json" }, "SceneLevels": [ { "NameMustMatchInFull": true, "Name": "KaiWars" } ] }
PAY SPECIAL ATTENTION: Unlike the other two descriptors, paths defined in a scene descriptor use the "Scenes" (StreamingAssets/Scenes) folder as the root. So for example, if my thumbnail texture lies in:

StreamingAssets/Scenes/KaiWars/Textures/KaiWars.png

I will define it in the descriptor as:

\\KaiWars\\Textures\\KaiWars.png

* SceneName: The name of the scene. If you have a localization file supplied, you can pass the key of the localization entry. If no key is found in the localization corresponding to what you provided, it will use your value as is.

* SceneDescription: The description of the scene. If you have a localization file supplied, you can pass the key of the localization entry. If no key is found in the localization corresponding to what you provided, it will use your value as is.

* SceneGamemode: The gamemode to start the scene in. This usually has no special effect because the coordinator won't run, but it might be useful in specific cases, like if you want to enable team splitting in your scene. Valid inputs are:

- Pointmatch
- TeamPointmatch
- Scorematch
- TeamScorematch
- Survival
- LastPlayerStanding
- LastTeamStanding
- BossRush
- TeamBossRush

* SceneThumbnail: Path to the scene thumbnail in .png format. The aspect ratio should be 16:9.

* SceneOnlineModes: An array of strings that define available hosting options. Valid inputs are:

- Public
- FriendsOnly
- SinglePlayer

* SceneLocalization: Object table that define paths to localization files for each language code. Here's what my "en" localization file looks like:

{ "values": { "KaiWars_SceneName": "Kai Wars", "KaiWars_SceneDescription": "Score as many frags as you can to reach the frag limit!", "KaiWars_PlayerWins": "%{player} wins!", "KaiWars_DuckSpawned": "BONUS DUCK spawned!", "KaiWars_PlayerGotDuck": "%{player} got a BONUS DUCK!" } }

* SceneLevels: An array of tables that define a level. You can set up multiple levels in one scene, and it will load one at random.

Each entry table must contain two keys: "NameMustMatchInFull" (bool) and "Name" (string).

"Name", of course, defines the name of the level. "NameMustMatchInFull" defines whether to search for one specific level named exactly as provided, or whether to gather all levels that contain the provided name. For example, if I want my scene to load every custom level that starts with "kw_" I will make the entry like so:

{ "NameMustMatchInFull": false, "Name": "kw_" }

This is useful if you want to make a specific gamemode that requires specific custom maps, and also allows other users to create maps for this gamemode you created.

* SessionVariables: Another array of tables that define a session variable. Session variables are variables that persist for as long the game is running, and don't get reset between level changes. This is useful, for example, if you want to create a script that will run on any level (through AutoRun), but will only activate if the right session variable has been set. Like a gamemode that can run on any level but must be activated from the scene.

SessionVariables are defined like so in the scene:

"SessionVariables": [ { "key": "myGamemode", "value": "1" } ]

And you get them from script with:

if GetSessionVariable("myGamemode") == "1" then print("My gamemode is active!") end

Remember that you can always generate this file through MicroKit as well.



We now have an easy and accessible way of loading our custom level. This on its own can be nice, allowing you to make sandbox type levels that you can chill out with your friends in. But what if you want more? What if we want to turn this level into a game?

Let's learn how to load code from a level.
Scene: Defining Lua Scripts
Making a lua script execute when you load your level is incredibly simple. First, you have to navigate to where your custom level is saved (the .lua file generated by MicroKit).

When you open that file, you'll notice it returns a lot of parameters, which is what MicroWorks processes. We're going to add a new parameter inside that return table, called "lua".

This lua parameter will be a table with a list of paths to each lua script you want to run when this level loads. Like the scene descriptor, the paths defined in the level descriptor take the level folder as the root, so if I will put my script inside:

StreamingAssets/CustomMaps/KaiWars/Assets/Scripts/KaiWars.lua

In code, the path will be:

\\Assets\\Scripts\\KaiWars.lua

So, anywhere inside the .lua descriptor, add the following:

lua = { "\\Assets\\Scripts\\KaiWars.lua" },

And that's it! Now when your level loads, the lua file specified in the path will execute. Let's create this file now, and write some game code.
Scene: Logic
The premise is very simple: When a player dies, the host will check who killed that player, and award them score. If they reached the score limit, they will be declared winners, and the level will restart.

Let's start with variables:

-- Access to coordinator local coordinator = worldInfo:GetCoordinator() -- Access to custom level manager local customLevelManager = worldInfo:GetCustomLevelManager() -- Parameters local scorePerFrag = 500 local scoreLimit = 10000 local gameOver = false

Let's create the function that will be called when a player should get awarded score. Most of the code should be self explanatory to you by now (you've grown up so fast.....)

-- Function to add score to a player that got a frag local function AddScore(player, score) -- Only execute on the host if not worldInfo:IsServer() then return end -- Award the score player:AddScore(score) -- Check whether they exceeded the score limit -- And the game's not over if player:GetScore() >= scoreLimit and not gameOver then -- This player won, let's lock all players movement -- And display a message for everyone local allPlayers = worldInfo:GetAllActivePlayers() for i, v in ipairs(allPlayers) do v:LockMovement(true) end -- Send a message to everyone -- GetI18nParams allows us to get an entry from the localization file, AND fill in dynamic values. -- For example, we can replace %{player} in the localization entry with the player's steam name -- By supplying as a second parameter, a table of tables, with each table containing the key of the value, and the value to fill in worldInfo:SendMessageToAll(GetI18nParams("KaiWars_PlayerWins", { { "player", player:GetSteamName() } }), 5) -- The game is over gameOver = true -- Wait 5 seconds, and restart the level RunAsync(function() Wait(Seconds(5)) local levelName = worldInfo:GetCurrentLevel() worldInfo:Traverse(levelName) end) end end

After that, we add the "PlayerDied" listener.

-- Listen to player died event ListenFor("PlayerDied", function(pay) -- Only execute on the host if not worldInfo:IsServer() then return end -- Get the killer player local killer = pay:GetKillerPlayer() -- Verify a killer actually exists -- (a player can die to the environment, in which case killer will be "nil") if killer ~= nil then AddScore(killer, scorePerFrag) end end)

Let's also add a listener to "PlayerSpawned", and check if the player that spawned was us. If that player was us, we'll make the health UI appear.

We have to do this on every spawn because the health UI hides itself when you die.

-- Listen to play spawned event ListenFor("PlayerSpawned", function(pay) local player = pay:GetPlayer() -- If this is ourselves, show the health UI if player:IsLocalController() then player:ShowHealthUI() end end)

And then at the very end: A function that will handle actually starting the gamemode. We will first clear everyone's score, because score persists between levels.

Then, we will enable the microgame HUD, and customize some stuff. We will call this start function as soon as we define it.



local function StartKaiWars() -- As the host, force set everyone's score back to 0 -- This is because coordinator persists score between levels if worldInfo:IsServer() then coordinator:ClearPlayerScores() end -- Wait a second and set up the hud RunAsync(function() Wait(Seconds(1)) coordinator:SetHudVisible(true) coordinator:SetRoundCounterIcon(kaiWarsIcon) coordinator:SetRoundText(GetI18n("KaiWars_SceneName")) coordinator:SetSpeedText("") end) end StartKaiWars()

I've added an icon texture to set as the round counter icon, and defined it at the beginning variables:

-- Assets local kaiWarsIcon = LoadResource("\\CustomMaps\\KaiWars\\Assets\\Textures\\KaiWarsIcon.png", ResourceType.Texture)

That's it! It's that simple. You can now launch your level and invite your friends for some Kai Wars action.


if only i had friends...
Scene: BONUS DUCKS
I promised you there would be ducks! Before we wrap everything up, let's do one final little duck script for good old times' sake.

Every random amount of time, we will spawn a duck at the center of the map that will hover and spin in the air. When a player uses the duck, they will gain bonus points.

Let's open up MicroKit again and load our level. I'll import the same duck model we've used in the microgame part of this tutorial series, and place it at the middle point, hovering above the air.

I'll also make sure to expose it to lua, and call it "BonusDuckModel". I'll also disable collision on the duck, and only make use of the player trigger we will add to it.

Finally, I will add an audio source in the same position, that will play when the duck is picked up. Of course, expose it to lua and call it "BonusDuckSound".



BEFORE YOU SAVE THE LEVEL: Keep in mind that this will erase the "lua" table we've added earlier into the level descriptor. Copy that table before you save, so you can paste it back in after you save.

With our level updated, let's return to the code, and define new variables for the bonus duck:

-- BONUS DUCK! local duckModel = customLevelManager:GetExposedEntityByName("BonusDuckModel") local duckSound = customLevelManager:GetExposedEntityByName("BonusDuckSound") local duckBonus = 1000 local duckSpinSpeed = 100 local PRNG = MakePRNG() local duckSpawnTimeMin = 20 local duckSpawnTimeMax = 90

The only things the host will sync is when the duck spawns, and when the duck is picked up. Other than those two, the host will keep track of who picked up the duck and when to spawn it.

Let's make two new functions: SpawnDuck() and HideDuck(). Make sure you specify the new duck functions after the "AddScore" function.

-- Function that spawns the duck local function SpawnDuck() -- Set the duck model to active duckModel:SetActive(true) -- Then we will initiate a loop that will spin the duck for as long as it is active RunAsync(function() while duckModel:IsActive() do -- Because we will rotate the duck every frame, -- We will multiply the rotation by the frame time, to ensure a consistent rotation speed -- No matter the FPS duckModel:GetTransform():RotateEulersRelative(Vector3Up() * duckSpinSpeed * worldInfo:GetFrameTime()) Yield() end end) end -- Function that hides the duck local function HideDuck() -- Set the duck as inactive duckModel:SetActive(false) -- Play the pickup sound duckSound:Play() end

And then, define the Server -> Client RPC's that will call these functions on the clients:

-- Server -> Client RPC -- Spawns the bonus duck RPC("SpawnDuckRPC", SendTo.Multicast, Delivery.Reliable, function() if worldInfo:IsServer() then return end SpawnDuck() end) -- Server -> Client RPC -- Hides the bonus duck RPC("HideDuckRPC", SendTo.Multicast, Delivery.Reliable, function() if worldInfo:IsServer() then return end HideDuck() end)

Now let's add a function for when the duck is picked up. Only the host will be aware of who picks up the duck, so this function will be host only:

-- Function when duck is collected -- Called only on the host local function OnDuckGet(player) -- Return if we are not the host if not worldInfo:IsServer() then return end -- Display message to everyone about who got it worldInfo:SendMessageToAll(GetI18nParams("KaiWars_PlayerGotDuck", { { "player", player:GetSteamName() } }), 3) -- Award the player bonus score AddScore(player, duckBonus) -- Disable the duck and tell clients to do so as well HideDuck() worldGlobals.HideDuckRPC() end

Finally, all that's left is a function to set up the duck. We will call this function at the end, together with "StartKaiWars()".

-- Function for doing initial set up on the duck -- We will call this on everyone when the level starts local function SetUpDuck() -- Disable the duck right away on everyone duckModel:SetActive(false) -- Return from this point on if we are not the host if not worldInfo:IsServer() then return end -- Listen to collisions on the duck duckModel:ListenToCollisions(true, 1.75) ListenFor("ObjectCollision", function(pay) if pay:HasPlayer() then OnDuckGet(pay:GetPlayer()) end end, duckModel) -- Then, we will start a loop that will run forever (well, for as long as the level is loaded) -- Every time, we will get a random amount of seconds -- When they pass, we will spawn the duck RunAsync(function() while true do -- Wait local waitAmount = PRNG:RangeF(duckSpawnTimeMin, duckSpawnTimeMax) Wait(Seconds(waitAmount)) -- Spawn duck SpawnDuck() worldGlobals.SpawnDuckRPC() -- Anounce duck has spawned worldInfo:SendMessageToAll(GetI18n("KaiWars_DuckSpawned")) -- Wait until the duck is inactive again, and start again Wait(Until(function() return duckModel:IsActive() == false end)) end end) end

And now we have a magic duck item that gives us points when we grab it!

Recap & Endword
So, to summarize, in order to make a scene, we:

  1. Add a scene descriptor (.scn) into StreamingAssets/Scenes

  2. We specify the scene data, including the levels it can load and the scene menu assets.

  3. To make a custom level load a script, we will define the path to it in the level descriptor.

Congratulations on making it... TO THE END! In this part, we learned how to create custom scenes, and how to load scripts in custom levels.

First of all, if you've made it all the way here, I bow before you. This is no quick & easy tutorial, and if you've made it this far, it sounds like you've made it. Congrats!

You should now have a full understanding of scripting in MicroWorks, and the power to create almost any microgame or boss stage you desire. You can even just make any level or gamemode you want!

We hope you've found the guide useful and intuitive, and we can't wait to see what you bring out to the workshop. We'll be watching!

Remember to join our Discord[discord.gg] server if you wish to connect with other modders or with the devs (hi!), we'll be more than happy to help you with any query.

Until next time!