Every 2D game ends up with an inventory. And almost every first inventory looks the same: an items[] array living in the scene, a few push calls, and a growing pile of if statements around it.
It works, until it doesn't. The bag is full and the potion silently disappears. An equip fails halfway and the sword is now in neither the slot nor the hand. A use callback throws and you've already removed the item.
I maintain a small vanilla JavaScript 2D engine for HTML5 Canvas (BeeEngine JS), and for version 2.10.0 I added an inventory. Here's how I designed it so it can't end up in those states.
Rule zero: plugins don't own the loop
Before the inventory, I built a tiny plugin registry (2.9.0). The engine does not import plugins. A plugin has attach / detach, and the engine exposes registerPlugin / unregisterPlugin. If a plugin really needs a frame, it uses engine.onTick(fn). It never calls loop() and never overrides update.
The first plugin was a localization helper, BeeLocale: read-only, with a fallback language, and missing keys return the key itself instead of an empty string. The inventory is the second plugin, built on the same pattern:
- it registers itself as
inventory - it has no
onTick - it doesn't draw anything
The scene decides how to show it. The inventory only keeps state honest.
The API in one snippet
import { BeeEngine } from 'beeengine';
import { BeeInventory } from 'beeengine/plugins';
const game = new BeeEngine('testCanvas', 800, 600);
const inventory = new BeeInventory({
slots: 1,
equipSlots: ['hand'],
engine: game
});
inventory.defineItem('potion', {
name: 'Potion',
maxStack: 5,
consumable: true,
onUse: () => true
});
const drop = inventory.add('potion', 8); // { added: 5, left: 3 }
inventory.use(0, null);
One slot, a stack limit of 5, and we try to add 8. The result isn't true or false: it's { added: 5, left: 3 }.
add always tells you what didn't fit
add fills existing stacks first, then empty slots. If there's no room left, it doesn't throw the extra items away. It returns them as left.
This sounds small, but it moves the decision to where it belongs. The inventory doesn't know whether the leftover potions should stay on the ground, go to a chest, or show a "Bag full" message. The game does. So the inventory just reports the truth and lets the scene decide.
remove works the other way around, from the last slot back to the first, so the stacks you filled first stay intact.
All or nothing: move, equip, unequip
These three either succeed completely or leave the state exactly as it was. No half-moved stacks, no item that vanished between the bag and the hand.
The idea is simple: check everything first, change things only when you know the whole operation can finish.
use with a rollback
use is the tricky one, because it runs user code: the item's onUse.
The rules:
- a piece is removed only if
onUsedoesn't throw and doesn't returnfalse - if
onUsereturnsfalse, nothing is consumed (for example "you're already at full health") - if
onUsethrows, the error is not swallowed: it propagates to the caller, and the inventory is restored to a snapshot taken before the call
I didn't want to hide bugs in game code with a silent try/catch. But I also didn't want a broken callback to leave the inventory corrupted. The snapshot gives you both: the error is loud, and the state stays clean.
Copies out, plain data in
get, list and equipped return copies. Code outside can't mutate the inventory by accident by editing an object it got back.
toJSON saves only slots and equipment. Item definitions (names, stack limits, onUse functions) stay in code, where they belong. A save file never contains functions, and changing an item's behaviour in an update doesn't require migrating old saves.
Two plugins, zero coupling
The demo drops coins and gems from the top of the screen. When the collector catches one, onCollect calls inventory.add. The counters on screen use locale.t(), and pressing L switches language.
The interesting part is what's not there: BeeInventory and BeeLocale don't import each other. The only thing that connects them is the scene. When the bag is full, the scene can read left and decide what to show, like a "Bag full" message.
Tests
A plain Node script:
node scripts/test-beeinventory.mjs
# BeeInventory tests ok
TypeScript types (BeeItemDef, BeeInventoryOptions, BeeInventory implements BeePlugin) ship in index.d.ts, and npm run typecheck passes.
And the engine core (BeeEngine.js) wasn't touched at all. That was the whole point of the plugin contract.
Why 2.10.0 and not 2.9.7
It's a new export, not a fix. Semver minor bump, even if it's "just" a plugin.
What's next
One plugin at a time: post-processing effects (fade, flash, shake) are next, then dialogue, which can reuse BeeLocale for its strings.
If you've built inventories before, I'm curious: do you return leftovers, or do you reject the whole add when it doesn't fit?
- npm: beeengine
- Docs: beeenginejs.com
- GitHub: BeeEngine

Top comments (0)