Skip to main content

Player Housing — Secret Base & Gym

MCE's housing feature is a buildable personal interior: a player owns a Secret Base (and, as a variant of the same engine, a custom Gym) that they place in the world, enter, and decorate on a tile grid. The whole build-and-decorate loop works 100% offline and persists in the local save; the online layer then adds two server-authoritative multiplayer tiers on top — a snapshot tier (visit a friend's base while they are offline) and a live co-presence tier (walk around inside a base together in real time).

house editor

⚠️ Read this screenshot with the section below in hand. The chrome is real -- item counter, furniture filters, visitor toggle, save -- but the panel in the middle says "House Grid Viewport" because that is exactly what it is: a placeholder. Five Secret Base methods work; eight are still NOT_IMPLEMENTED.

Everything the server stores here is server-authoritative and fail-closed: the Nakama backend PARSES every decor placement and every gym-leader monster and rejects anything out of bounds, overlapping, over-cap, or forged — a client can never write a base or gym it did not legitimately build. This has been verified by adversarial pentest.

Two systems, one engine

The Secret Base (SecretBase* types) and My Gym (Gym* types) share the same interior/decor engine. The gym adds a leader roster, a custom badge, and dialog keys on top of the base's decorated room. This page documents the base first, then the gym-specific additions.

Layer 1 — Offline Secret Base (Core, no server required)​

The offline base lives entirely in Assets/OpenMon/Core/Runtime/World/SecretBase/ and needs no online connection.

ClassFileResponsibility
SecretBaseManagerWorld/SecretBase/SecretBaseManager.csPlace/remove the base, add/remove/move decor, ownership queries
SecretBaseDataWorld/SecretBase/SecretBaseData.csSerializable base state (anchor + decor list), saved in GlobalGameData
DecorCatalogWorld/SecretBase/DecorCatalog.csScriptableObject registry of DecorEntry (id, prefab, icon, footprint)
SecretBaseInteriorLoaderWorld/SecretBase/SecretBaseInteriorLoader.csTurns saved decor into the visible interior scene
SecretBaseInteriorSessionWorld/SecretBase/SecretBaseInteriorSession.csStatic "am I inside my interior right now" session pointer
SecretBaseDecorateControllerWorld/SecretBase/SecretBaseDecorateController.csOpens the in-interior decoration UI

Placing and entering a base​

A base is placed at a world cell (typically from the PlaceSecretBase out-of-battle item effect), which drops the reserved overworld anchor:

var manager = new SecretBaseManager(globalGameData, gridController);
PlacementResult result = manager.TryPlaceBase(cell, new SecretBaseData());

The player enters through the "Mi Base" (My Base) entry in the pause menu (GameMenuScreen). Outside the interior it teleports the player into an instanced interior scene (SecretBaseInteriorSession); inside the interior the same entry opens the decoration controller instead.

Decorating​

Decor is placed on the interior tile grid (default interior size is SecretBaseManager.DefaultInteriorSize = 8×8):

manager.AddDecor(decorId: "bookshelf_oak", tile: new Vector3Int(2, 3, 0), rotation: 1);
manager.MoveDecor(from: new Vector3Int(2, 3, 0), to: new Vector3Int(4, 3, 0));
manager.RemoveDecorAt(new Vector3Int(4, 3, 0));

rotation is a 0..3 quarter-turn step (the loader applies rotation * 90°). The manager raises OnDecorChanged after every mutation so the live view and the online co-presence layer can react.

Persistence​

The base is part of the normal save — no separate file, no server required:

  • GlobalGameData.SecretBase (SecretBaseData) holds the placed base and its decor.
  • SavableGlobalGameData.SecretBase carries it into the JSON save (null-tolerant for old saves).

Layer 2 — Snapshot multiplayer (F3-A, visit an offline friend)​

The online Secret Base methods live on HousingService (Assets/OpenMon/Online/Runtime/Housing/HousingService.cs), reached via:

var housing = NakamaManager.Instance.Housing;

They round-trip through the server module Assets/OpenMon/Online/Server~/nakama/src/world/secretbase.ts using the four secret_base_* RPCs.

Half of that class is not implemented — know which half

HousingService carries two APIs, and only one of them works.

WorksDoes not
SaveBaseAsync, VisitBaseAsync, LeaveBaseAsync, InviteToBaseAsync, ListBasesAsyncPurchaseHouseAsync, PlaceFurnitureAsync, RemoveFurnitureAsync, VisitHouseAsync, SaveHouseAsync, SetVisitorsAllowedAsync
The Secret Base / Gym feature this page documents, on the secret_base_* RPCsA legacy house API — eight methods that log NOT_IMPLEMENTED and return nothing. No housing_* RPC exists server‑side.

The EnableHousing flag in OnlineConfig gates only the legacy half, and it ships off as of 2026‑08‑30. It used to ship on, which meant a warning on every login and no house — a toggle promising a feature the engine does not have. Secret Base and Gym never needed it and are unaffected. ::: The base interior is stored on the server so another player can VISIT it even while the owner is offline (a static snapshot).

RPCs and storage​

RPC (RpcIds)Purpose
secret_base_saveOwner uploads its OWN base snapshot (validated fail-closed)
secret_base_getRead ANOTHER player's base by ownerId (owner may be offline)
secret_base_listPublic browse foundation (lightweight cards)
secret_base_invitePush a visit invitation (notification code 300)

Storage uses collection secret_bases, key base, under the owner's userId — one base per user; the request can never choose the key (always ctx.userId). Permissions are permissionRead:2 (PUBLIC — a visitor reads another player's base) and permissionWrite:0 (server-only — the client cannot bypass the RPC and write directly). The stored value is { snapshot, savedAt }.

Saving your base​

SaveBaseAsync maps the Core SecretBaseData to the flat wire snapshot via SecretBaseWireMapper (stripping the reserved anchor), then runs the same optimistic-concurrency (OCC) ladder as Cloud Save:

bool ok = await housing.SaveBaseAsync(secretBaseData);

The wire payload is a SecretBaseSaveRequest:

[Serializable]
public class SecretBaseSaveRequest
{
public string baseVersion; // last-known OCC version ("" on first save)
public SecretBaseSnapshotWire snapshot; // { gridWidth, gridHeight, decor[] }
}

[Serializable]
public class SecretBaseDecorWire
{
public string decorId;
public int tileX, tileY, tileZ;
public int footprintW, footprintH;
public int rotation; // 0..3 quarter-turn step
public string customData;
}

On success the server returns { success:true, conflict:false, version } and the new version is cached in HousingService.LastKnownVersion. On an OCC conflict it returns { success:false, conflict:true, serverVersion, serverData:{ snapshot } } — the service surfaces the authoritative snapshot in LastConflictSnapshot and returns false. The last writer never silently wins; the caller must merge and retry.

Visiting a base​

SecretBaseData theirBase = await housing.VisitBaseAsync(ownerId);
// Feed a non-null result into SecretBaseInteriorLoader.VisitOverride with ReadOnlyVisit = true.

await housing.LeaveBaseAsync();

VisitBaseAsync calls secret_base_get. If the owner has no base (or the fetch fails) it returns null — it never fabricates a room. A visited base is rendered read-only through SecretBaseInteriorLoader.VisitOverride.

Inviting and browsing​

bool sent = await housing.InviteToBaseAsync(targetUserId, "Come visit my base!");
SecretBaseListResponse page = await housing.ListBasesAsync(cursor);

Invitations are delivered as Nakama notifications (code 300) and pair with the friend graph. ListBasesAsync is an additive public-browse foundation; the shipping UX stays invite/friends.

Fail-closed validation (server-side)​

Before any write, validateSnapshot / validateDecorPlacement in secretbase.ts reject the entire save (zero writes) if anything fails:

  • Grid dims must be integers in [1, 32]; decor count <= 128.
  • Each decorId is a non-empty string <= 64 chars and never the reserved anchor __mce_secret_base_anchor__.
  • Tile/footprint/rotation must be integers; tileZ within [-64, 64]; footprint within [1, 32]; rotation 0..3.
  • Every placement is bounds-checked and AABB overlap-checked against all prior accepted placements (effective footprint swaps W/H on an odd rotation step).
  • Serialized blob capped at 1 MiB.

Layer 3 — Live co-presence (F3-B, walk around together)​

BaseSyncService (Assets/OpenMon/Online/Runtime/Housing/BaseSyncService.cs), reached via NakamaManager.Instance.BaseSync, adds live presence inside an interior on top of the F3-A snapshot. It is a bounded clone of WorldSyncService pointed at a relayed Nakama match created by name base:<ownerId> — zero new server surface (the existing beforeMatchDataSend guard applies automatically).

  • The host is the base owner in their own interior; visitors are joiners and are strictly read-only.
  • Positions broadcast over OP_POSITION (op code 1, the same schema as world sync); the map field is the match name, so the existing client-side AOI filter partitions the interior.
  • The host re-publishes decor edits over the new op code OP_DECOR_CHANGED (5), carrying a SecretBaseSnapshotWire delta. A visitor calling PublishDecorChanged is refused — a visitor's client can never drive a server broadcast.
// Host (owner) entering their own interior:
await NakamaManager.Instance.BaseSync.EnterBaseAsync(myUserId, asHost: true);

// Visitors are wired automatically: BaseSync.BindHousing(Housing) makes
// HousingService.VisitBaseAsync/OnVisitorEntered join base:<ownerId> as a visitor.

Remote avatars reuse PlayerNetworkEntity / PlayerSpawnManager verbatim — the service raises the same OnPlayerEnterAOI / OnPlayerExitAOI / OnPlayerPositionUpdate events as world sync. Offline (no socket) every method is a safe no-op: the base still renders locally, you just see no live avatars.

My Gym — server-authoritative challenge snapshot (F2b)​

A Gym is the gym variant of the same interior engine: the player builds a full-custom gym (a leader roster drawn from their own party, a decorated room, a custom badge, and intro/victory dialogs), and another player can challenge it while the owner is offline. The challenge battle runs client-side from the fetched snapshot — this slice is storage + fail-closed validation + badge-award integrity, not a server battle engine.

Client (Core, offline)​

ClassFileResponsibility
GymManagerWorld/Gym/GymManager.csEstablish/name the gym, set badge & dialogs, pick leader slots, decorate the room
PlayerGymDataWorld/Gym/PlayerGymData.csSerializable gym state (room, leader slots, badge, dialogs)
GymInteriorLoader / GymInteriorSessionWorld/Gym/Instanced gym interior (reuses the base interior's dynamic exit)
var gym = new GymManager(globalGameData);
gym.EstablishGym("Boulder Gym");
gym.SetBadge("badge_boulder", "Boulder Badge");
gym.SetDialogs(introDialogKey, victoryDialogKey);
gym.SetLeaderSlots(new[] { 0, 1, 2 }, rosterSize);
gym.AddDecor("trophy_case", new Vector3Int(1, 1, 0), rotation: 0);

The gym reuses the "Mi Gimnasio" (My Gym) pause-menu entry, mirroring the "Mi Base" flow, and persists in GlobalGameData.PlayerGym / SavableGlobalGameData.PlayerGym. GymManager.CanChallenge(ownerId, challengerId) gates who may challenge (you cannot challenge your own gym).

Online (GymOnlineService)​

GymOnlineService (Assets/OpenMon/Online/Runtime/Housing/GymOnlineService.cs), reached via NakamaManager.Instance.Gym, round-trips through Assets/OpenMon/Online/Server~/nakama/src/world/playergym.ts on a dedicated collection player_gyms (key gym, permissionRead:2 / permissionWrite:0) — NOT folded into secret_bases, because a gym has a different lifecycle and shape.

RPC (RpcIds)Purpose
gym_saveOwner uploads its OWN gym snapshot (room + leader roster + badge, validated fail-closed)
gym_getFetch ANOTHER player's gym to challenge it (owner may be offline)
gym_listPublic browse foundation (owner/name/badge/leader count)
// Save (leaderRoster is the frozen team, built from the owner's roster upstream):
bool ok = await gym.SaveGymAsync(playerGymData, leaderRoster);

// Challenge (fetch the frozen snapshot, then run the battle client-side):
GymSnapshotWire theirGym = await gym.ChallengeGymAsync(ownerId);

GymListResponse page = await gym.ListGymsAsync(cursor);

The gym wire snapshot carries the leader roster plus the gym metadata and reuses the base room wire verbatim:

[Serializable]
public class GymSnapshotWire
{
public MonsterSnapshot[] leaderRoster; // 1..6, re-validated by the anti-cheat seam
public string badgeId; // non-empty; integrity-checked server-side
public string badgeName;
public string gymName;
public string introDialogKey;
public string victoryDialogKey;
public SecretBaseSnapshotWire room; // the SAME decorated-room wire a base uses
}

Fail-closed validation (server-side)​

validateGymSnapshot in playergym.ts reuses the Secret Base decor ruleset for the room verbatim and adds gym-specific integrity checks — all fail-closed (any single failure ⇒ zero writes):

  • Room — validated by the SAME validateSnapshot a Secret Base uses (no AABB re-implementation).
  • Leader roster — an array of 1..6 monsters, each re-validated through the same anti-cheat seam battles use (validateMonsterSnapshot — stat/HP/move caps; throws on impossible values → the save is rejected). A challenger can never face a forged super-team.
  • badgeId — non-empty and bounded (<= 128); the badge a challenger earns on defeat must not be forgeable or cross-user writable.
  • Free-text (badgeName / gymName / dialog keys) — bounded, default "".
  • Serialized blob capped at 1 MiB. OCC (gymVersion) mirrors Secret Base / Cloud Save.

Legacy house/furniture API​

HousingService still exposes the old template-house / furniture methods (PurchaseHouseAsync, PlaceFurnitureAsync, RemoveFurnitureAsync, VisitHouseAsync, SaveHouseAsync, SetVisitorsAllowedAsync). These are honest NOT_IMPLEMENTED stubs — they log a warning and return null/false without fabricating any state. The shipping housing feature is the Secret Base / Gym engine documented above; the legacy template-house model was never wired to a server contract. Housing.InitializeAsync is likewise a no-op and only runs when OnlineConfig.EnableHousing is enabled.