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).

⚠️ 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.
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.
| Class | File | Responsibility |
|---|---|---|
SecretBaseManager | World/SecretBase/SecretBaseManager.cs | Place/remove the base, add/remove/move decor, ownership queries |
SecretBaseData | World/SecretBase/SecretBaseData.cs | Serializable base state (anchor + decor list), saved in GlobalGameData |
DecorCatalog | World/SecretBase/DecorCatalog.cs | ScriptableObject registry of DecorEntry (id, prefab, icon, footprint) |
SecretBaseInteriorLoader | World/SecretBase/SecretBaseInteriorLoader.cs | Turns saved decor into the visible interior scene |
SecretBaseInteriorSession | World/SecretBase/SecretBaseInteriorSession.cs | Static "am I inside my interior right now" session pointer |
SecretBaseDecorateController | World/SecretBase/SecretBaseDecorateController.cs | Opens 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.SecretBasecarries 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.
HousingService carries two APIs, and only one of them works.
| Works | Does not |
|---|---|
SaveBaseAsync, VisitBaseAsync, LeaveBaseAsync, InviteToBaseAsync, ListBasesAsync | PurchaseHouseAsync, PlaceFurnitureAsync, RemoveFurnitureAsync, VisitHouseAsync, SaveHouseAsync, SetVisitorsAllowedAsync |
The Secret Base / Gym feature this page documents, on the secret_base_* RPCs | A 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_save | Owner uploads its OWN base snapshot (validated fail-closed) |
secret_base_get | Read ANOTHER player's base by ownerId (owner may be offline) |
secret_base_list | Public browse foundation (lightweight cards) |
secret_base_invite | Push 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
decorIdis a non-empty string<= 64chars and never the reserved anchor__mce_secret_base_anchor__. - Tile/footprint/rotation must be integers;
tileZwithin[-64, 64]; footprint within[1, 32]; rotation0..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 code1, the same schema as world sync); themapfield 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 aSecretBaseSnapshotWiredelta. A visitor callingPublishDecorChangedis 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)
| Class | File | Responsibility |
|---|---|---|
GymManager | World/Gym/GymManager.cs | Establish/name the gym, set badge & dialogs, pick leader slots, decorate the room |
PlayerGymData | World/Gym/PlayerGymData.cs | Serializable gym state (room, leader slots, badge, dialogs) |
GymInteriorLoader / GymInteriorSession | World/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_save | Owner uploads its OWN gym snapshot (room + leader roster + badge, validated fail-closed) |
gym_get | Fetch ANOTHER player's gym to challenge it (owner may be offline) |
gym_list | Public 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
validateSnapshota 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.