Skip to main content

MongoDB design

MongoDB holds the off-chain authoritative game state. It never pretends to be the authority for token balances or staking deposits — Solana is.

Run a replica set from development onward: it enables transactions and change streams.

Collections

players wallet_link_challenges game_sessions
alpacas player_alpacas missions
player_missions inventories inventory_ledger
staking_positions blockchain_events reward_ledger
seasons leaderboard_entries outbox_events
audit_logs nft_entitlements

Login sessions are Keycloak's concern — the backend stores no refresh tokens. wallet_link_challenges holds the short-lived nonces used when a player links a Solana wallet to their account.

players

Players are keyed by the Keycloak subject; the wallet is optional and set by the wallet-link flow:

{
"_id": "01J2PLAYER...",
"keycloakSubject": "f3a9c1d2-...",
"wallet": "7xK...",
"displayName": "CosmicAlpaca",
"level": 12,
"experience": 8450,
"selectedAlpacaId": "01J2ALPACA...",
"energy": { "current": 75, "maximum": 100, "updatedAt": "2026-07-17T11:00:00Z" },
"status": "active",
"createdAt": "2026-07-01T09:00:00Z",
"updatedAt": "2026-07-17T11:00:00Z"
}
db.players.createIndex({ keycloakSubject: 1 }, { unique: true })
db.players.createIndex({ wallet: 1 }, { unique: true, sparse: true })
db.players.createIndex({ displayName: 1 }, { unique: true, sparse: true })

player_alpacas

{
"_id": "01J2ALPACA...",
"playerId": "01J2PLAYER...",
"templateId": "cosmic-white-001",
"name": "Luna",
"rarity": "legendary",
"level": 8,
"experience": 1880,
"attributes": { "speed": 14, "luck": 21, "stamina": 18 },
"equipment": { "head": "golden-hat", "body": null }
}
db.player_alpacas.createIndex({ playerId: 1, createdAt: -1 })
db.player_alpacas.createIndex({ playerId: 1, rarity: 1 })

player_missions

{
"_id": "01J2MISSION...",
"playerId": "01J2PLAYER...",
"missionDefinitionId": "moon-wool-expedition",
"alpacaId": "01J2ALPACA...",
"status": "running",
"energyCost": 20,
"startedAt": "2026-07-17T11:00:00Z",
"completesAt": "2026-07-17T12:00:00Z",
"result": null,
"version": 1
}
db.player_missions.createIndex({ playerId: 1, status: 1, completesAt: 1 })
db.player_missions.createIndex({ status: 1, completesAt: 1 })

staking_positions

A synchronized projection of the on-chain position — never the authority:

{
"_id": "staking-account-address",
"playerId": "01J2PLAYER...",
"wallet": "7xK...",
"stakingAccount": "9Ab...",
"amountRaw": "5000000000000",
"tokenDecimals": 9,
"lockType": "ninety_days",
"multiplierBps": 15000,
"startsAt": "2026-07-01T12:00:00Z",
"unlocksAt": "2026-09-29T12:00:00Z",
"lastObservedSlot": 420000000,
"lastSignature": "5tx...",
"status": "active"
}
warning

Store blockchain integer amounts as strings or BSON decimals. Never convert token amounts to floating point.

inventory_ledger

Never store only a balance. Every balance change is an append-only ledger entry explaining how the player got there:

{
"_id": "01J2LEDGER...",
"playerId": "01J2PLAYER...",
"resource": "wool",
"quantityDelta": 25,
"balanceAfter": 1025,
"reason": "mission_reward",
"referenceType": "player_mission",
"referenceId": "01J2MISSION...",
"idempotencyKey": "mission:01J2MISSION...:wool",
"createdAt": "2026-07-17T12:00:01Z"
}
db.inventory_ledger.createIndex({ idempotencyKey: 1 }, { unique: true })

The unique idempotency index prevents the same mission reward from being credited twice.

blockchain_events

{
"_id": "transaction-signature:event-index",
"signature": "5tx...",
"instructionIndex": 2,
"eventType": "stake_created",
"slot": 420000000,
"commitment": "confirmed",
"wallet": "7xK...",
"stakingAccount": "9Ab...",
"payload": { "amountRaw": "5000000000000", "lockDurationSeconds": 7776000 },
"processingStatus": "processed"
}

The _id is signature:instructionIndex, so replays are naturally idempotent.

Index policy

Indexes improve reads but cost storage and write throughput. Only create indexes that support real access patterns — the ones above are the minimum set for the game loop.

Transactions

Use multi-document transactions for any operation touching more than one collection (mission completion, reward claims), and put the corresponding outbox_events insert inside the same transaction. See Backend → Transactions and the outbox.