DEV Community

Antonio
Antonio

Posted on

An inventory plugin that never lies: transactional add, equip and use in a vanilla JS 2D engine

invwntario

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);
Enter fullscreen mode Exit fullscreen mode

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 onUse doesn't throw and doesn't return false
  • if onUse returns false, nothing is consumed (for example "you're already at full health")
  • if onUse throws, 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
Enter fullscreen mode Exit fullscreen mode

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?

Top comments (0)