Skip to main content

Secret Bases & Player Gyms

MCE ships a single-player buildable-interior slice: the player can enter an instanced room they own, decorate it from a catalog, run an endless Battle Tower, rematch beaten gym leaders, and even stand up their own custom gym with a leader team and a custom badge. Everything here is local — it rides the normal save with no schema migration.

house editor

The same editor backs secret bases. The furniture filters and the placement counter are live; the grid viewport is still a placeholder, so treat the layout half as unfinished.

Multiplayer

Visiting other players' bases, syncing furniture over the wire, and server-authoritative housing are the online layer's job. This page covers only the single-player systems. For the networked, visitable version see the Player Housing guide.

All four features are reached from the in-game pause menu (GameMenuScreen, cases 8–11) — see the Menu System guide for how the entries are wired and gated.

Overview​

FeaturePause-menu entryPersistent home (in the save)
My Base (decorate)Mi Base (case 10)GlobalGameData.SecretBase
My Gym (custom gym)Mi Gimnasio (case 11)GlobalGameData.PlayerGym
Battle Tower (endless)Torre de Batalla (case 9)ephemeral — not saved
Gym battle / rematchGimnasio (case 8)GymDefinition asset (authored)

My Base (Secret Base)​

Player-facing flow​

Selecting Mi Base branches on where the player is standing (GameMenuScreen.EnterOrDecorateSecretBase):

  • In the overworld → the current tile is stashed as the return location and the player is teleported into the instanced interior scene via the same PlayerTeleporter warps use.
  • Already inside the interior → the in-interior decorate palette opens (SecretBaseDecorateController.Open).

The interior is a single instanced room. The player's saved decor is drawn on load and re-synced whenever it changes.

Decorating​

SecretBaseDecorateController is a menu-driven place/remove flow (it reuses DialogManager.ShowChoiceMenu — no new UI framework). Decor is placed on the tile the player faces (the same facing-tile math the "Base Kit" item uses):

  • Place — pick a decor from the catalog palette, then it is placed on the faced tile.
  • Remove — whatever decor covers the faced tile is removed (the base's reserved world anchor is protected and can never be deleted this way).

All legality (bounds + overlap) lives in the pure SecretBasePlacement core — the controller only gathers the player's choice and calls the manager.

The persistence model​

A Secret Base is a plain, JsonUtility-serializable POCO that rides GlobalGameData.sav with no save-format migration (the same precedent as FollowerData):

[Serializable]
public class PlacedDecor
{
public string DecorId; // opaque id, resolved against a DecorCatalog
public Vector3Int Tile; // footprint origin, in real grid coords
public int FootprintWidth = 1;
public int FootprintHeight = 1;
public int Rotation;
public string CustomData; // opaque per-placement data (variant/colour)
}

[Serializable]
public class SecretBaseData
{
public List<PlacedDecor> PlacedDecor = new();
}

Under the single-base model, the base's overworld anchor cell is persisted as a reserved-id PlacedDecor (SecretBaseManager.BaseAnchorDecorId) inside that same list, so the anchor rides the existing persistence with zero new save fields.

The runtime seam​

SecretBaseManager is a plain POCO bridge between a world/menu interaction and the verified SecretBasePlacement core plus the save. It owns none of the placement rules — it delegates every legality decision to the pure core and only adds two responsibilities the core deliberately leaves out: the walkability predicate and persistence into GlobalGameData.SecretBase.

MemberPurpose
TryPlaceBase(Vector3Int cell, SecretBaseData data)Anchor a base at an overworld cell (validates + persists)
RemoveBase(Vector3Int cell)Clear the single-base save field
AddDecor(string decorId, Vector3Int tile, int rotation = 0)Resolve against Catalog, validate against InteriorBounds, append to the save
RemoveDecorAt(Vector3Int tile)Remove the decor covering a tile (refuses the anchor)
MoveDecor(Vector3Int from, Vector3Int to)Remove-then-re-place; reverts on an invalid target
InteriorBounds (RectInt)The only placeability rule the interior uses
Catalog (DecorCatalog)The id → prefab/sprite/footprint resolver
OnBasePlaced / OnBaseRemoved / OnDecorChangedEvents the world/interior visuals listen to

Setting it up as a developer​

The systems are code-complete; wiring the scenes and assets is the dev's job. Nothing here is invented — every type below is a real class under Assets/OpenMon/Core/Runtime/World/.

1. Author a decor catalog​

Create a DecorCatalog asset (menu MCE ▸ World ▸ Decor Catalog). Each DecorEntry is a plain serializable holder:

[Serializable]
public class DecorEntry
{
public string DecorId; // stable, opaque, unique in the catalog
public string DisplayNameLocKey; // localization key shown in the palette
public GameObject Prefab; // spawned when the interior is populated
public Sprite Icon; // palette/menu icon
public int FootprintWidth = 1;
public int FootprintHeight = 1;
}

The catalog is a sandbox resolver: every entry is always available — there are no unlock flags, economy, or gating in this slice. Lookup is O(1) via TryGet(decorId, out entry).

2. Build the interior scene​

Add a SecretBaseInteriorLoader to the interior scene. It is the single MonoBehaviour that owns the room: it constructs a runtime SecretBaseManager bound to the live GlobalGameData, hands it the interior seams (the DecorCatalog and the InteriorBounds rectangle), publishes it into SecretBaseRegistry and SecretBaseInteriorSession, then instantiates one prefab per PlacedDecor and re-syncs on OnDecorChanged.

Serialized fields to assign:

FieldMeaning
catalogThe DecorCatalog handed to the manager
interiorBoundsRoom rectangle, in this scene's tile coords (default 8×8)
gridScene Grid for tile→world mapping (falls back to identity coords)
decorContainerParent for instantiated decor (defaults to the loader's transform)
arrivalMarkerAuthoring marker for where the player arrives
IPlayerDataReceiver is mandatory

The loader implements IPlayerDataReceiver because PlayerDataInstaller binds GlobalGameData with .WhenInjectedInto<IPlayerDataReceiver>(). Without the marker the [Inject] is silently skipped and the interior renders empty at runtime even though every offline test passes.

3. Wire the pause-menu entry​

On the GameMenu prefab (GameMenuScreen), assign:

  • secretBaseInteriorScene — the SceneInfoAsset of the interior template.
  • secretBaseInteriorSpawn — the tile the player arrives at (should sit inside interiorBounds).

Until secretBaseInteriorScene is assigned, the Mi Base enter step is a safe no-op. The return tile is captured on GlobalGameData.SecretBaseReturn and round-trips through the save, so the interior's dynamic exit (ExitSecretBaseInterior) brings the player back exactly where they left.

4. (Optional) Show the base in the overworld​

SecretBaseSpawner turns OnBasePlaced / OnBaseRemoved into a visible prefab. Bind the runtime manager with SetManager(...) (which also registers it into SecretBaseRegistry so the "Base Kit" item can resolve it), and author baseVisualPrefab. Under the single-base model, a new placement despawns any previous base visual first. The spawn/despawn/grid-mapping are injectable seams (SetSpawnFactory, SetDespawner, SetCellToWorld) so the flow is edit-mode testable without a live scene.


My Gym (player-owned gym)​

Mi Gimnasio (case 11) is the gym variant of the base flow. It reuses the exact decor engine — a SecretBaseData Room validated by the same pure SecretBasePlacement core — and adds the gym-specific data a base has no notion of.

First-entry provisioning​

On the first overworld entry (GameMenuScreen.EnsureGymEstablished), the gym is established with sane defaults:

  • The gym is marked Established.
  • The leader team defaults to the player's current party (each occupied roster slot).
  • A placeholder badge id is assigned so a challenger has something to earn.

This is idempotent: an already-established gym is left as-is except that a still-empty leader team is defaulted.

The persistent model​

[Serializable]
public class PlayerGymData
{
public bool Established;
public string GymName = "";
public SecretBaseData Room = new(); // reuses the F2 decor engine wholesale
public List<int> LeaderRosterSlots = new();// indices into the player's own Roster
public string BadgeId = ""; // custom badge a challenger earns
public string BadgeDisplayName = "";
public string IntroDialogKey = "";
public string VictoryDialogKey = "";
}

Everything lands in the existing save fields (GlobalGameData.PlayerGym / GlobalGameData.EarnedGymBadges), so it round-trips with no migration.

The runtime seam​

GymManager is to the gym what SecretBaseManager is to the base — a plain POCO that delegates all decor legality to the shared core and adds the gym-only concerns:

MemberPurpose
EstablishGym(string gymName = null)Create the gym (idempotent)
HasGym()Has the player created their gym?
SetLeaderSlots(IEnumerable<int> slots, int rosterSize)Replace the leader team (sanitized, deduped, in-range)
HasLeaderTeam(int rosterSize)Is at least one leader slot valid?
SetBadge(string badgeId, string badgeDisplayName = null)Set the custom badge challengers earn
SetDialogs(string introKey, string victoryKey)Set the leader battle dialog keys
AwardEarnedBadge(string badgeId)Award the badge to a challenger once
CanChallenge(string ownerId, string challengerId)Pure rule: an owner can never earn their own gym's badge
AddDecor / RemoveDecorAt / MoveDecorSame decor API as the base, over the gym Room

Building the gym interior scene mirrors the base exactly: add a GymInteriorLoader (also an IPlayerDataReceiver), assign its catalog and interiorBounds, and set gymInteriorScene + gymInteriorSpawn on the GameMenu prefab. The gym interior reuses the base interior's dynamic exit, so it stashes the return location the same way (the two interiors are never entered simultaneously).

note

The frozen leader snapshot a remote visitor would challenge (so the owner can be offline) is the server slice — the local model references the leader team by roster slot, which is all the client-side custom gym needs.


Gym battles & rematches​

Gimnasio (case 8) launches a practice/rematch battle against the menu's configured gym leader. It sources its launch context exactly like a normal overworld trainer fight (SingleTrainerFight) and re-enters the overworld grid on a win.

A gym is authored as a GymDefinition ScriptableObject (menu MCE ▸ Gyms ▸ Gym Definition) that aggregates the primitives a gym is composed of:

FieldPurpose
LeaderNameLocalizationKeyLeader display-name loc key
LeaderRosterThe leader's battle roster (needs ≥ 1 non-null monster)
LeaderCharacter (CharacterData)Required — the battle dereferences it for the trainer front sprite
LeaderBag (Bag)Required — the items module clones the enemy trainer's bag
BadgeReward (Badge)Awarded when the leader is defeated
TrainerRostersOptional sub-trainers cleared before the leader
IntroDialogKey / DefeatDialogKey / PostBattleDialogKeyDialog loc keys through the battle flow
LeaderCommandGraphGenerated leader battle flow (from Gym Builder)
GymOrderOptional ordering hint (1 = first gym)

Assign the GymDefinition to GameMenuScreen.menuGymDefinition in the menu prefab. When the slot (or its LeaderRoster) is unassigned, the entry is a safe no-op.


Battle Tower​

Torre de Batalla (case 9) spawns a throwaway, scene-less Battle Tower on demand via EphemeralTowerLauncher.Launch(diContainer, playerCharacter). It stands up a TowerController + TowerRewardPayout on a fresh GameObject, runs the endless loop, and tears the GameObject down the moment the run reaches any terminal state (loss, abort, or an illegal-team rejection).

Key properties:

  • Ephemeral — it deliberately does not mirror run-state into GlobalGameData.TowerRun, so a menu-launched run never leaks into a save. It lives and dies with its GameObject.
  • Real rewards — money is still paid: the payout is bound straight to the player's real wallet (player.PlayerBag), matching the authored StartTowerRun path.
  • Endless single-battle — TowerController re-launches the next battle from each battle's own end-callback and continues until a loss, firing set-completion events at each set boundary.

Opponents are anonymous — generated from the player's roster references, they carry no CharacterData or Bag. The battle system was hardened to tolerate this (see Battle System ▸ anonymous trainers).

Out-of-the-box balance​

TowerBalanceDefaults supplies a playable fallback rule-set and reward curve so a fresh tower is not empty. A designer overrides these on TowerController (serialized) or via SetRules:

  • Rule-set — level cap 50, auto-level to cap on, team size 3, single battles, Species Clause on (no duplicates), legendaries banned, set length 7.
  • Reward curve (streak → money) — 0 → 500, 7 → 1500, 21 → 5000, 49 → 15000.
caution

The default numbers are best-judgment placeholders — tune them against your shop price table before shipping.