Obenseuer Modding Framework new game options API
Status: experimental.
What it is
When the player starts a new game, the game asks a few questions about the character. This API lets your mod add its own questions to that screen: a switch that is on or off, a list of named choices, a whole number inside a range, or a number that moves in steps inside a range. The player’s answers are kept with the save, so after the game starts your mod can read them and change how it behaves for that character. The identity of an option is the pair of your mod id and the key you give it, so two mods can use the same key without clashing.
The framework adds one row for each registered option to the game’s own difficulty screen, the one the player passes while creating a character. The player changes a value with the arrows on the row, and the answer is kept with the save and read back at any time.
When to use it
- Offer a setting that belongs to one character rather than to the whole installation, such as a starting bonus or a difficulty tweak.
- Let the player choose between named modes at the start of a game, and remember the choice in the save.
- Read the player’s choices for this save and apply them, for example by setting difficulty factors again at the first load or new game of a game session.
Quick example
This mod registers one of each kind of option, reads the number once the save is ready, and writes a log line when a new game starts.
using ObenseuerCore;
[ModInfo("example.startbonus", "Start Bonus", "0.1.0")]
[ModDependency("obenseuer.moddingframework", "0.11.1")]
public sealed class StartBonus : FrameworkMod
{
protected override void OnLoad()
{
NewGameOptions.RegisterToggle("example.startbonus", "extra", "Starting bonus", "Start with a small cash bonus.", false);
NewGameOptions.RegisterChoice("example.startbonus", "tier", "Bonus size", "How large the bonus is.", new string[] { "small", "large" }, new string[] { "Small", "Large" }, 0, NewGamePlacement.CustomPanel);
NewGameOptions.RegisterInteger("example.startbonus", "count", "Bonus amount", "The number of coins the bonus pays.", 10, 1000, 50);
NewGameOptions.RegisterFloat("example.startbonus", "rate", "Bonus rate", "How fast the bonus pays out.", 0.5f, 2f, 0.25f);
Events.WhenReady(Apply);
Events.NewGameStarted += OnNewGame;
}
private void Apply()
{
int count = NewGameOptions.GetInteger("example.startbonus", "count", 50);
float rate = NewGameOptions.GetFloat("example.startbonus", "rate", 1f);
string tier = NewGameOptions.GetChoice("example.startbonus", "tier", "small");
Log.Info("This save's bonus is " + count + " coins, tier " + tier + ", rate " + rate + ".");
}
private void OnNewGame()
{
Log.Info("A new game started. The game's difficulty is not applied yet here.");
}
}
Register in OnLoad: that is the one moment a framework mod is guaranteed to run before a save or a new game can fix the option list. Events.WhenReady runs your code once a save is loaded or a new game’s first level has loaded, which is when the values are there to read. See Using events.
Common tasks
Read a switch once a save is loaded. Pass the same default you registered, so a save made before your mod was installed reads as the default.
Events.WhenReady(() =>
{
if (NewGameOptions.GetBool("example.startbonus", "extra", false))
Log.Info("The starting bonus is on for this save.");
});
Re-apply a difficulty factor at the first load or new game of a game session, because the game’s difficulty factors do not survive a save load. The WhenReady call below does that once per session; to cover a later save load in the same session, call the same method from Events.Loaded too.
Events.WhenReady(() =>
{
bool extra = NewGameOptions.GetBool("example.startbonus", "extra", false);
GameDifficulty.SetFactor("example.startbonus", DifficultyMultiplier.NeedsSpeed, extra ? 1.2f : 1f);
});
GameDifficulty factors are session only. The game rebuilds them from the save it loads, so a factor your mod set in an earlier session is gone. Read your option again and set the factor at the first load or new game of a game session. The example above does exactly that, since Events.WhenReady runs its action once. To set the factor again after a later save load in the same session, call the same method from Events.Loaded too: re-applying a difficulty factor is safe to repeat. See HUD, console, achievements and difficulty.
React the moment a new game starts, before the first level shows. Events.NewGameStarted has no argument. Events.NewGameStartedInfo is raised at the same moment with a NewGameInfo that holds the character’s name, the chosen difficulty and the chosen option values. The older event keeps working, so a mod written against it needs no change.
Events.NewGameStarted += () => Log.Info("The player confirmed a new character.");
Events.NewGameStartedInfo += info => Log.Info((info.CharacterName == null ? "The new character" : info.CharacterName) + " starts on " + (info.Difficulty == null ? "an unknown difficulty" : info.Difficulty.Name) + ".");
Keep saved values stable. Give each choice a short id that describes the answer, not the position in the list. Reordering the ids and titles arrays later does not change what an old save means.
Complete example
This mod adds three options to the New Game screen and a difficulty preset for each pace choice. At the first load or new game of a game session, it reads the choices back and applies the preset that matches the pace. The presets are built on the game’s own Easy and Hard levels.
using ObenseuerCore;
[ModInfo("example.frontierstart", "Frontier Start", "0.1.0")]
[ModDependency("obenseuer.moddingframework", "0.11.1")]
public sealed class FrontierStart : FrameworkMod
{
protected override void OnLoad()
{
NewGameOptions.RegisterToggle(Mod.Id, "bonus", "Starting bonus", "Start with a cash bonus.", false, NewGamePlacement.CustomPanel);
NewGameOptions.RegisterChoice(Mod.Id, "pace", "Pace", "How fast the needs run.", new string[] { "relaxed", "normal", "tough" }, new string[] { "Relaxed", "Normal", "Tough" }, 1, NewGamePlacement.CustomPanel);
NewGameOptions.RegisterInteger(Mod.Id, "coins", "Bonus coins", "The coins the bonus pays.", 0, 500, 50, NewGamePlacement.CustomPanel);
GameDifficulty.Register(Mod.Id, "relaxed", "Relaxed", "Easy, with slower needs.", DifficultyLevel.Easy, settings => settings.NeedsSpeed = 0.5f);
GameDifficulty.Register(Mod.Id, "tough", "Tough", "Hard, with faster needs.", DifficultyLevel.Hard, settings => settings.NeedsSpeed = 1.5f);
Events.WhenReady(ApplyChoices);
}
private void ApplyChoices()
{
string pace = NewGameOptions.GetChoice(Mod.Id, "pace", "normal");
if (pace == "normal") GameDifficulty.ClearPreset(Mod.Id);
else if (!GameDifficulty.SetPreset(Mod.Id, pace)) Log.Warn("The " + pace + " preset was not applied");
if (NewGameOptions.GetBool(Mod.Id, "bonus", false))
Log.Info("The starting bonus of " + NewGameOptions.GetInteger(Mod.Id, "coins", 50) + " coins is on for this save.");
Log.Info("Pace " + pace + ", running preset " + (GameDifficulty.ActivePreset ?? "none"));
}
}
The options are registered in OnLoad, which is before any save loads, so the New Game screen shows them. Events.WhenReady runs ApplyChoices once a save is loaded or a new game’s first level is up. The example only logs the bonus, so pay it with whatever call fits your mod. A value given to a preset is matched to the nearest step the game has, as the difficulty presets page explains. GameDifficulty.SetPreset works only on a loaded save, which is why it runs in WhenReady and not in OnLoad. A registered preset is also listed on the screen’s own difficulty selector, so the player can pick one there instead of through an option.
Reference
This is the full list of what the API gives you.
NewGameOptions registers and reads the New Game options
Register each option once from OnLoad, then read its value with the Get call that matches its kind.
public static class NewGameOptions
{
public static bool RegisterToggle(string modId, string key, string title, string description, bool defaultValue);
public static bool RegisterToggle(string modId, string key, string title, string description, bool defaultValue, NewGamePlacement placement);
public static bool RegisterChoice(string modId, string key, string title, string description, string[] ids, string[] titles, int defaultIndex);
public static bool RegisterChoice(string modId, string key, string title, string description, string[] ids, string[] titles, int defaultIndex, NewGamePlacement placement);
public static bool RegisterInteger(string modId, string key, string title, string description, int min, int max, int defaultValue);
public static bool RegisterInteger(string modId, string key, string title, string description, int min, int max, int defaultValue, NewGamePlacement placement);
public static bool RegisterFloat(string modId, string key, string title, string description, float min, float max, float step, float defaultValue);
public static bool RegisterFloat(string modId, string key, string title, string description, float min, float max, float step, float defaultValue, NewGamePlacement placement);
public static bool GetBool(string modId, string key, bool fallback);
public static string GetChoice(string modId, string key, string fallback);
public static int GetInteger(string modId, string key, int fallback);
public static float GetFloat(string modId, string key, float fallback);
}
NewGameOptions.RegisterFloat a number that moves in steps
Registers one number for the New Game screen. The row moves one step at a time and turns round at min and max. The value is kept as the whole number of steps from min, so the value read back is min plus the step count times step, and it never drifts the way added up decimals would. The row shows the value with up to two decimal places, so a step smaller than 0.01 makes more than one step show the same text.
| Argument | Type | What it means |
|---|---|---|
modId | string | Your mod id. It must be a valid mod id. |
key | string | The name of the option. It must not be empty. |
title | string | The label on the row. It must not be empty. |
description | string | Shown in the game’s own description panel while the row is selected. It may be empty. |
min | float | The lowest value the row offers. It must be less than max. |
max | float | The highest value the row offers. |
step | float | How much one arrow press adds or takes away. Greater than zero, and (max - min) / step must be a whole number of steps, at most 1000. Float rounding is allowed for, by a small allowance that grows with the size of the numbers, 0.0001 of a step for small values. |
defaultValue | float | The value the row starts on. It must be min, max or a value on a step between them. |
placement | NewGamePlacement | Where the row goes in the list. BelowDifficulty when you leave it out. |
NewGameOptions.GetFloat reads a float option for this save
Gives back the value for the save, or the fallback you pass when there is none. Pass the same value you registered as the default.
| Argument | Type | What it means |
|---|---|---|
modId | string | Your mod id. |
key | string | The name of the option. |
fallback | float | The value given back when the save has no value for the option. |
- The value is worked out from the option’s own range and held between
minand the value of the last step, so a step index edited into the file by hand cannot give back a value outside the row’s range. - A saved value that is not a step index, and a key that is not a registered float option, give back the
fallbackand write one line to the log.
</div>
NewGamePlacement where an option's row goes
Pass this as the last argument of a register call. It is BelowDifficulty when you leave it out.
public enum NewGamePlacement
{
BelowDifficulty = 0,
CustomPanel = 1
}
| Value | Meaning |
|---|---|
BelowDifficulty | The row goes with the game’s own difficulty settings.This is the default. The row is shown on every difficulty, with or without the game’s own rows. Turning off theNewGameRowsOnAllDifficulties setting puts the row back with the game’s block, and so back to the Custom difficulty alone. |
CustomPanel | The row goes after the game’s settings.Use it for a mod that wants its options at the end of the list. It is the same panel, only lower down. |
</div>
NewGameInfo the character and choices of a new game
Passed to Events.NewGameStartedInfo. It holds the name the player typed, the chosen difficulty and the chosen value of each option.
public sealed class NewGameInfo
{
public string CharacterName { get; }
public string CharacterFirstName { get; }
public string CharacterLastName { get; }
public DifficultySettings Difficulty { get; }
public ReadOnlyCollection<NewGameOptionValue> Options { get; }
}
NewGameOptionValue one chosen option value
One entry of NewGameInfo.Options. Value is the id string for a choice, 1 or 0 for a switch, the number for a whole number, and the step index for a float, which GetFloat turns back into the value.
public sealed class NewGameOptionValue
{
public string ModId { get; }
public string Key { get; }
public string Title { get; }
public string Value { get; }
}
NewGameOptionInfo how the framework holds a registered option
The framework keeps one of these for each option a mod registered. The type itself is internal, and the two members below are named here because they say what a float option’s stored step index means. Both are 0 for a toggle, a choice and a whole number.
| Member | Type | What it means |
|---|---|---|
FloatMin | float | The value at step 0, which is the min the option was registered with. 0 when the option is not a float. |
FloatStep | float | How much one step adds, which is the step the option was registered with. 0 when the option is not a float. |
- Register from
FrameworkMod.OnLoad. A registration returnstruewhen the option was added. It returnsfalseand writes one line to the log when the samemodIdandkeyare already registered, or when the option list is already fixed, which happens once a save loads or a new game starts. The log line says to register fromOnLoad.- Technical: the option list is fixed at the first
FrameworkMod.OnLoadcycle’s end of the new game or save load. Registration order is the order the screen will show.
- Technical: the option list is fixed at the first
- The pair of
modIdandkeynames one option. The pair is matched exactly, soLoudandloudare two options. Two mods may use the same key. titleanddescriptionare the text for the screen.titleis the label on the row.descriptionis shown in the game’s own description panel while the row is selected, so it may be empty and nothing breaks.placementis where the row goes in the list of difficulty settings.NewGamePlacement.BelowDifficulty, the default, puts the row with the game’s own difficulty settings.NewGamePlacement.CustomPanelputs it after them, for a mod that wants its options at the end of the list. Both placements put the row in the game’s one row panel: the custom panel is not a separate block, so a row placed there looks the same and is only further down.- A row shows the value the player can change. A toggle shows
EnabledorDisabled, a choice shows the matchingtitlesentry, a whole number shows the number itself and moves one step at a time, turning round atminandmax, and a float shows the value itself and moves onestepat a time, turning round atminandmax. An unknownplacementvalue, a whole number whosedefaultValueis outsideminandmax, and a float whosedefaultValueis outsideminandmaxor sits between two steps are refused: the call returnsfalseand writes one line to the log. - A choice is stored as its id string, never as a position.
idsholds the stable ids,titlesholds the text the player sees, anddefaultIndexsays which entry is selected to begin with. The two arrays must have the same length. - An integer is stored as its number, and
minandmaxsay what the row allows the player to pick. - A float is stored as a whole number of steps from
min, never as the value itself, so a value is always worked out the same way and never drifts.stepsays how much one arrow press adds, and(max - min) / stepmust be a whole number of steps of at most 1000, so every value the row can show is a value the framework can store. Float rounding is allowed for, so a range whose step count is very slightly off a whole number is still taken: the allowance is small and grows with the size of the numbers,0.0001of a step for small values, far less than half a step. That is what keeps a largeminsuch as1000with astepof0.1and a default of1050.3working, while a default halfway between two steps,1050.35, is still refused. - Bad arguments throw
ArgumentException: an invalid mod id, an empty key or title, a null or emptyidsortitles, an empty string inside either array, arrays of different lengths, adefaultIndexoutside the list, aminlarger thanmax, or, for a float, amin,max,stepordefaultValuethat is not a finite number, aminthat is not less thanmax, astepthat is not greater than zero, or a(max - min) / stepthat is not a whole number of steps of at most 1000.descriptionmay be empty. An unknownplacementvalue, a whole number with adefaultValueoutsideminandmax, and a float with adefaultValueoutsideminandmaxor between two steps do not throw: the call returnsfalseand writes one line to the log. GetBool,GetChoice,GetIntegerandGetFloatgive back the value for the save, or thefallbackyou pass when there is none. Pass the same value you registered as the default.- Technical: reads look in the values loaded from the save first, then in the values chosen for a new game that has not saved yet, then give the fallback.
- A saved value that your code cannot read gives the fallback and writes one line to the log, for example a number edited into the file by hand.
- A read for an option that was never registered, or for one with no value yet, gives the fallback and writes one line to the log. A value of
0orfalseis a value, not a missing one. - Values are written when the save is written, the first time and every time after that, and they are read when a save loads. The first save of a new game is the moment the chosen values leave memory and become part of the save.
- A save made before your mod existed simply has no entry for your options. Nothing is written for it, every read gives your fallback, and the file gains your values the next time that save is written.
Events
Events.NewGameStartedis anActionwith no argument. It fires when a player confirms a new character, after the framework has taken the chosen values into memory and reset its own per-save state.Events.NewGameStartedInfois anAction<NewGameInfo>, raised at the same moment and in the same order asEvents.NewGameStarted.CharacterName,CharacterFirstNameandCharacterLastNameare the name the player typed,Difficultyis the difficulty chosen on the same screen, andOptionsholds the chosen value of every registered option in registration order.NewGameStartedstays, so an older mod is not broken. Register a handler for one of the two, not for both, or your code runs twice.- The game’s difficulty is not applied yet when
Events.NewGameStartedfires. A handler must not read the difficulty or the character’s applied start there. Read it later, for example from the first level’sEvents.WhenReadyor the firstEvents.SceneLoaded.- Technical: the event is raised in the postfix of
NewGame.SaveCharacter, and the game applies the chosen difficulty one frame after the new scene starts, inPlayerIdentity.StartDelay.
- Technical: the event is raised in the postfix of
Events.Loadedis anActionwith no argument, raised once a save has loaded and the framework’s saved values are in memory.Events.WhenReadyis the easier way to run code at that point. A game loaded from a save never raisesEvents.NewGameStarted.
Details
Target: Obenseuer 0.5.08 (buildid 25969064), Unity 6000.0.77f1 Mono, BepInEx 6 Unity.Mono (be.788, the tested build) or BepInEx 5 (5.4.23 or later). Namespace: ObenseuerCore. Logs go to BepInEx\logs\obenseuer.moddingframework.startoptions.log for the values and to BepInEx\logs\obenseuer.moddingframework.newgamescreen.log for the rows.
Everything not listed in this reference is internal and may change without notice. This page is experimental and may change too.
Where the values live
The framework keeps one file per save for every mod’s new game options: Saves\<character>\<save>\mods\obenseuer.moddingframework.startoptions.json. It is written when the save is written and read when the save loads, like the framework’s other per-mod save files. A mod that is uninstalled leaves its entries in that file behind, where they do nothing.
The New Game screen
The rows are added to the character difficulty screen in the main menu, the one where the game’s difficulty is chosen and the character’s name, age and gender are typed. Each row is a copy of one of the game’s own setting rows, so it has the same look, the same left and right arrows, the same sounds and the same gamepad handling, and the game’s navigation list is refreshed after the rows are added. Rows are added in registration order, first the rows placed with the difficulty settings and then the rows placed after them, so one mod’s rows are not kept together when that mod uses both placements. A mod with more than one option has the mod id in front of each of its labels.
The framework adds the rows once for each visit to that screen. Nothing is written by a row until the player presses an arrow, and the values are taken into memory when the player confirms the character, which is the moment both Events.NewGameStarted events fire.
The rows are added to the game’s own block of difficulty settings, which the game shows only when the chosen difficulty is Custom and hides for the named difficulties. On every visit the framework brings that block back up and leaves the game’s own setting rows as the difficulty wants them, so its own rows show on every difficulty while the game’s own rows still show only on Custom. The NewGameRowsOnAllDifficulties setting in the Mods menu’s UI section, on by default, turns that off and gives back the game’s own behaviour: use it when the rows overlap the difficulty description. A row is copied from a live game object and the framework cannot see the prefab offline, so the exact place of a row inside the copied layout and the order the game’s gamepad navigation sees depend on the game’s screen.
When you can call it
- Call everything from your mod’s normal code, not from a background thread you started. The game and Unity only work safely on one thread, the main thread (see the glossary). That includes the code that handles
Events.NewGameStarted. - Register during
FrameworkMod.OnLoad. Later registration returnsfalseand writes one line to the log. - Reading is safe at any time. Before a save loads and before a new game saves, you get the fallback you passed.