Entity Storage: three services, three scopes, one game
Persistent State: that article's four tiers are about which mechanism to use; this one shows what the CRUD calls actually look like for the three that are raw JSON entities rather than currency or lobby state.
brainCloud gives you four entity services for storing structured JSON, and unreal-bootcamp's NetworkManager.cpp exercises three of them in one file. Every entity, regardless of which service holds it, has the same underlying shape: a brainCloud-generated unique id, a developer-defined entityType string, JSON data, and version-based optimistic concurrency, an update only succeeds if the version you send matches the one currently on the server. Entities are created and edited entirely at runtime; that's what separates them from Global Properties, which are portal-authored config a client only ever reads. Choosing between the three comes down to one question: who needs to see this record?
- User Entity is scoped to a single player. It's always owned by that player, with no unowned form at all, and it's the natural home for save-game data: level completion, inventory, anything that belongs to one account. It's what shows up per-player in the portal's Player Monitoring.
- Global Entity is scoped to the whole app: shared data every client reads the same way. An ordinary global entity still has an owner, whichever account's client created it; clearing that owner turns it into a System Entity, brainCloud's name for app-owned data with nobody's personal account behind it. It's viewed and edited app-wide in the portal's Global Monitoring.
- Custom Entity is also app-wide, but built for scale rather than a handful of records: it layers pagination, search, and sort into a single query, the shape to reach for once a Global Entity collection would otherwise keep growing without bound. It's defined as a collection in the portal's Custom Entities design section, and, unlike the other two, that collection can be configured owned or unowned depending on what it's used for.
- A fourth option, Group Entity, extends the same idea to a group's members instead of one player or the whole app. It's real, but this reference app doesn't touch it; see Groups for the group side of this series.
Each entity also carries an ACL governing what other users may do with it, separate from who owns it. The exact access-control scale isn't the point here: what decides which service and which methods you reach for is the scope above, not the permission detail layered on top of it.
How it works
- All three share one shape. A unique id, an entityType string, JSON data, and a version number that gates every update, so a write only lands if nothing else changed the record since you last read it.
- User Entity is private by default, and this app makes that explicit. createEntity passes a locked-down ACL (EAcl::NONE), so nobody but the owning player can read or write it, which is the right default for save-game-shaped data like level completion flags.
- Global Entity is looked up by a designer-assigned key, not queried. This app fetches with getListByIndexedId(indexedId, maxReturn, ...), a direct lookup by a key like "current level definitions", closer to a hash-table read than a search, and the client only ever reads it. Whether the record is owned by an account or is a fully app-owned System Entity doesn't change that lookup.
- Custom Entity is the one built for scale and search. getEntityPage takes a pagination block, a search criteria block, and a sort criteria block, all as one JSON context. This app uses it to page through a "country leaderboard" custom-entity type sorted by data.score, descending, 10 rows at a time, app-level data rather than one record per player. Whether the collection is owned at all is a one-time schema decision (the collection's isOwned setting), not something flipped per call.
- All of them follow the same create/read/update pattern, with different scoping arguments: entityType plus JSON data plus an ACL to write, and a lookup key appropriate to that service to read.
Code
void UNetworkManager::CreateUserEntityData()
{
FString jsonData = "{\"levelOneCompleted\":\"false\",\"levelTwoCompleted\":\"false\",...}";
IAcl* acl = UBrainCloudACL::CreateNewACL(EAcl::NONE); // owner-only, nobody else can read this
m_BrainCloud->getEntityService()->createEntity(kBrainCloudUserDataEntityType, jsonData, acl, &m_CreateUserEntityDataCallback);
}m_BrainCloud->getEntityService()->getEntitiesByType(kBrainCloudUserDataEntityType, &m_GetUserEntityDataCallback);
// ...
m_BrainCloud->getEntityService()->updateEntity(entityID, entityType, jsonData, acl, &m_UpdateUserEntityDataCallback);void UNetworkManager::RequestGlobalEntityLevelData()
{
m_BrainCloud->getGlobalEntityService()->getListByIndexedId(kBrainCloudLevelDataIndexedID, 5, &m_GetGlobalEntityLevelDataCallback);
}void UNetworkManager::RequestCountryLeaderboard()
{
FString jsonContext = "{\"pagination\": {\"rowsPerPage\": 10,\"pageNumber\": 1},"
"\"searchCriteria\": {},"
"\"sortCriteria\": {\"data.score\": -1}}";
m_BrainCloud->getCustomEntityService()->getEntityPage(kBrainCloudCountryLeaderboardEntityType, jsonContext, &m_GetCustomLeaderboardCallback);
}Gotchas worth knowing up front
- Pick the service by scope first. Player-only data goes to User Entity. Shared, admin-authored config at modest scale goes to Global Entity, usually as a System Entity so it belongs to the app rather than to a developer's account. Large or actively-growing collections go to Custom Entity. Using User Entity for something that needs cross-player querying, or Custom Entity for a handful of private per-player fields, fights the grain of each service's actual design.
- Owning an entity ties its lifetime to the player. A User Entity, or an owned Global or Custom Entity, is deleted automatically if its owning user is deleted. That's exactly what you want for save data, and exactly what will quietly destroy shared or reference data, so an app-level record wants to be unowned even when a player's client is what created it.
- getListByIndexedId is a direct key lookup, not a query. That's why Global Entity is fast, and also why it's built for smaller, largely static collections rather than ones that grow without bound.
- Custom Entity is the one built for real querying. Search criteria, sort criteria, and pagination, on large or actively-growing collections, the case Global Entity's single indexed-key lookup wasn't built for. If a Global Entity collection keeps growing or needs filtering and sorting instead of one known key, that's the signal to move to Custom Entity, not a reason to keep stretching Global Entity past its design.
- getEntityPage on an owned Custom Entity collection is scoped to the caller by default. It returns only the entities the calling player owns; another player's entity is simply absent, which reads as missing data rather than a permission boundary. Widening it to include entities other players own too is an explicit "options": {"ownedOnly": false} block in the query context.
- Ownership is a client-facing boundary, and Cloud Code can step around it. Custom Entity's sys-prefixed methods (sysReadEntity, sysUpdateEntity, sysCreateEntity, and the rest) bypass the ownership and ACL checks the client-facing calls enforce, and are callable only from Cloud Code or S2S. If a rule has to hold no matter what the client sends, that boundary is where it goes, the same pattern as Cloud Code, the pattern.