Permissions & Quotas
The containment contract: what you must declare, what you get, and what happens when limits are hit. None of it is optional - quotas are always on, for every plugin, in every world.
Permissions
Declared in the manifest, shown to server owners at install time, enforced server-side where the host mediates the capability: undeclared use logs denied: requires the '<permission>' permission and no-ops.
| Permission | Gates | Enforcement |
|---|---|---|
world.entities | ctx.entities.* | hard (server) |
storage | ctx.storage.* | hard (server) |
players.modify | ctx.rewards.grantMoney/grantXp | hard (server) |
players.read | player list APIs | informational in apiVersion 2 (reads are snapshot-only) |
world.objects.read | ctx.objects.list/get (server), useWorldObjects (client) | hard (both) |
world.objects.write | ctx.objects.setFarm/setProperties on owned presets | hard (server) |
players.inventory | ctx.inventory.count/grant/remove | hard (server) |
audio | client audio bus + usePluginAudioBuffer | hard (client SDK returns a disabled stub) |
ui.hud | HudPanel surface | informational in apiVersion 2 |
physics.colliders | curated static/kinematic/client-local dynamic colliders, server-authoritative dynamic entity boxes, and closest-hit raycast via @vibelands/plugin-sdk/physics | hard (client capability and server dynamic-body spawn both reject access without permission) |
Unknown permission ids fail manifest validation in check and at server discovery.
Quotas & budgets
| Resource | Limit | Over-limit behavior |
|---|---|---|
| Replicated entities | 384 per plugin per world | spawn() returns null, logged |
Entity properties JSON | ≤ 2 KB per entity | spawn/update rejected |
| Entity transform updates | 10 Hz per entity, host-batched | faster updates merge into a pending patch - final state always lands |
| Replicated KV blob | ≤ 8 KB, ≤ 2 writes/s (burst 4) | over-rate writes defer, last write wins |
| Persistent storage | ≤ 32 KB per value, ≤ 2 MB per plugin per world | set() returns false, logged |
| Inbound messages | 20/s per plugin per client (burst 40), ≤ 8 KB | silently dropped |
| Server tick budget | 100 ms synchronous hook time per 5 s window | strike; 3 consecutive strikes → disabled for that room |
| Hook errors | 5 per 5 s window | disabled for that room |
| Client frame budget | advisory ~1.5 ms avg | visible in the debug panel; egregious cases get flagged |
| Client bundle | ≤ 1.5 MB gzip | local check and the hosted build fail |
| Assets | ≤ 25 MB per plugin | local check and the hosted build fail |
Awaited I/O (ctx.storage, DB latency) never counts against the tick budget - only CPU you actually burn.
The circuit breaker
When a plugin trips the budget or error threshold:
- it's disabled for that room only - other worlds running it are unaffected
- its timers are cleared, its replicated entities and KV state removed
- a loud
DISABLEDline lands in the server log disabledReasonappears inGET /admin/pluginshealth and the in-game store
Re-enabling via the store/admin API is the owner's explicit "try again" - it restarts the plugin fresh.
Client-side containment
- Every surface mounts inside its own React error boundary: a render crash unmounts that plugin only and flags it in
window.__VIBELANDS_PLUGINS__.status(). usePluginFrameswallows and counts callback errors; after 50 the callback is disabled.- Load failures (bad integrity, missing bundle, no surfaces) skip the plugin with a visible warning - never a white screen.
Observability
// Browser console
window.__VIBELANDS_PLUGINS__.status() // { [id]: { status, error? } }
window.__VIBELANDS_PLUGINS__.frameMetrics() // { [id]: { avgMs, maxMs, samples } }# Admin API - per-world live health
curl -H "x-admin-key: $KEY" http://localhost:2567/admin/worlds/<id>/plugins
# → health: [{ pluginId, enabled, disabledReason, entityCount, windowExecMs, overBudgetStrikes }]Visual panel: bottom-left of the screen at debugMode ≥ 1 - status, [installed] source marker, avg/max frame ms per plugin.