Class-Based Item Registration
Last updated
May be stale
This page was last updated ago, on 25 August 2026. Check what it says against the code before relying on it.
Code:
core/libraries/sh_itemcore.lua(theITEMclass),core/libraries/sh_item.lua(cityrp.item.new,cityrp.item.register, the registry) History: metatable-based item library introduced 2019-08-21 (efcf7cb22);ITEMsplit out ofcityrp.iteminto its own file 2020-05-02 (d366624eb)
This document was written after the fact. It records what the class-based registration route is, why it replaced cityrp.item.register({}), the decisions baked into it, and the sharp edges those decisions left behind. The usage guide is items.md; the action hooks are in item-actions.md; metadata is in item-metadata-schema.md. This page is about why the route is shaped the way it is.
Problem
Before this route, every item was a plain table handed to cityrp.item.register(tbl, baseID). Two problems drove the redesign:
- Inheritance required a registered parent.
register(tbl, "base_x")looks the base up incityrp.item.stored, so the base had to be a registered — and therefore obtainable — item. Anything that wanted to share data (ten suits at the same price, a family of backpacks) had to either duplicate the fields or publish an abstract base that players could see in the market unless something explicitly hid it. This is AGENTS.md footgun #6. - One item per file, lots of boilerplate. Each variant was a full table with its own
name,cost,model,description,uniqueIDassignments. Families of near-identical cosmetic items (the bulk of the catalogue) were the worst case.
Goals
- Share data between variants without registering the shared part.
- Define several related items in one file with minimal repetition.
- Keep the runtime contract (
cityrp.item.stored[id], thecanX/onXhooks,cityrp.item.get) identical so every consumer — inventory, market, manufacturing, entities — keeps working unchanged. - Coexist with the legacy route indefinitely. Migrating the legacy files was never in scope; today 368 files use
ITEM:Newand 250 still callcityrp.item.register.
Non-goals
- Replacing the registry.
cityrp.item.storedis still a flatuniqueID -> tablemap. - Validating item definitions (required fields, model existence, category names). Registration still accepts whatever it is given.
- Changing the hook naming. The UpperCamelCase/lowerCamelCase split described at the top of
sh_itemcore.lua(SetCostvscanUse) is deliberate: construction-time API vs runtime hooks.
Design
Two layers
| Layer | Where | Responsibility |
|---|---|---|
ITEM class |
sh_itemcore.lua |
The global base table every item ultimately indexes into. Holds defaults (size = 1, cost = 0, batch = 5, store = true, category = "Base", model = "models/error.mdl"), the fluent setters, New/Clone/Merge/Register, and the default canX = true hooks. |
cityrp.item |
sh_item.lua |
The registry and its lookup/category helpers. cityrp.item.new(base) builds the metatable link; cityrp.item.register(item) post-processes and stores. |
ITEM:Register(id) is a thin wrapper: it sets uniqueID if given, then calls the legacy cityrp.item.register(self) with no base argument. The fluent route therefore reuses all the legacy normalisation (default canX, replaced/outdated handling, .rep to .reps fix-up, GetMetaName, the long-category warning) rather than duplicating it. There is exactly one registration code path.
Inheritance is a metatable chain, not a copy
ITEM <--__index-- stored["base_accessory"] <--__index-- (unregistered local BASE) <--__index-- stored["backpacknavy_2"]
ITEM:New("base_id")→cityrp.item.new("base_id")→setmetatable({base = id, BaseClass = stored[id]}, {__index = stored[id]}).ITEM:New()with no base →setmetatable({}, ITEM)(ITEM.__index = ITEM).item:Clone()→setmetatable({base = rawget(self, "uniqueID") or "tmp_base_<file>"}, {__index = self}).
This is the decision that solves goal 1. A clone's parent is whatever table it was cloned from, registered or not, so an intermediate BASE can exist purely in the file's local scope. The chain terminates at a registered base (which in turn chains to ITEM), so every fluent setter and every default hook is reachable from the leaf.
Contrast with the legacy route, which does table.Inherit(item, baseTab) — a shallow copy of the base's fields onto the item at registration time — and sets the item's metatable to the base only as a fallback.
Consequence (intended): the shared part is never stored, so it can never be bought, spawned, or listed.
Consequence (accepted): fields are resolved lazily. Mutating a base after its children are registered changes the children too, because nothing was copied. uponLoad handlers, which run over every stored item after all item files have loaded, rely on this being safe.
The fluent API
Setters return self so a whole item is one expression. Most setters are generated by ITEM:AutoFunction(name, key) from a datas table (Cost/Price → cost, Description/Desc → description, Category/Cat → category, Team, Batch, Member → membershiplevel, REP/REPs → reps, CanUse, Size, LootName, LootPlural). The aliases exist so call sites read naturally; they are not separate fields.
Hand-written setters exist where one call sets more than one field or has true-means-copy semantics:
SetName(name, plural)also setsplural(defaultname .. "s";true→ same as name;""→false).SetModel(model, skin, autoStore)/SetSkin(skin, autoStore)/SetStoreModel/SetStoreSkin—truecopies from the other side (inventory ↔ store),autoStorepushes to the other side. This exists for cosmetic items whose inventory model and market preview differ.SetStore(val)—false/0hides from the market; a number both enables the store and setsbatch.SetColor/SetMaterial— appearance overrides, consumed bycityrp.item.applyAppearance.SetMetaSchema/AddMetaField/RemoveMetaField/SetMetaFieldOrder— see item-metadata-schema.md.CanUse/OnUse,CanGive/OnGive, …CanPlace/onPlace— one pair per action in item-actions.md.CanX(nil)resets to the always-true default.Replaced(by)/Replacement(new, old)— mark an ID as superseded.Replacementbuilds a stub that inherits from the new item and registers it under the old ID with the replaced flag, so the legacyonUseoverride tells the player what to buy instead.
SetData(key, value) and Merge(tbl) are the escape hatches for fields with no setter. ITEM:New also accepts a data table in either argument position (New("base", {...}), New({...}, "base"), New({...})), which is how table-style definitions are bolted onto the fluent route without a rewrite.
Load order
Registration order matters because cityrp.item.new(base) looks the base up at call time. sh_init.lua enforces it:
core/items/<folder>in a fixed list, withbasefirst, then ammunition, cars, clothing, …, collectables.cityrp.plugin.LoadSubResourceItems— plugin-owneditems/folders, after core so they can inherit from core bases.item:uponLoad()on every stored item.CityRP.Core.PostItemLoad.
Within a folder, files load in file.Find order. A base defined in the same folder as its children is only safe if it sorts first — which is why the real bases live in core/items/base/.
Alternatives considered
These are reconstructed from the shape of the code rather than from a record of the original discussion.
- Keep
register(tbl, base)but add ahiddenflag to bases. Rejected in effect: it still pollutes the registry, still needs every consumer (market,/giveitem, loot tables,cityrp.item.getfuzzy matching) to honour the flag, and doesn't reduce boilerplate.store = falsealready exists and is exactly this half-measure; it hides from the market only. - Copy-on-register for the fluent route too (
table.Inherit). Would make registration-order mistakes fail loudly (the base is fully materialised) and remove the late-mutation hazard, but breaksuponLoad-style post-processing of bases and costs memory across a catalogue of several hundred items. Lazy chaining was kept. - A separate new registry. Rejected; dual registries would have forced every consumer to check both. Reusing
cityrp.item.registerfor post-processing is why the fluent route got the legacy fix-ups for free.
Known sharp edges
Things the design leaves open, documented so they are not rediscovered as bugs. None of these are new; they are consequences of the decisions above.
| Edge | Cause | Effect |
|---|---|---|
| Misspelled or not-yet-loaded base is silent. | cityrp.item.new(base) falls through to setmetatable({}, ITEM) when stored[base] is nil. |
ITEM:New("base_accesory") registers a working item with ITEM defaults (models/error.mdl, category Base) and no error. Same outcome if the base's file loads later. |
Legacy bases do not expose ITEM methods. |
A table registered via cityrp.item.register with no base has no metatable, so a chain rooted at it never reaches ITEM. |
ITEM:New("legacy_base") returns a table on which :SetCost is nil. cityrp.item.applyAppearance is a plain function rather than a method for this reason. Fluent items must inherit from fluent or ITEM-rooted bases. |
Setters called on the global ITEM change every item. |
All setters write to self; nothing stops self being ITEM itself. |
Always start a chain with New or Clone. |
| Clone base IDs are synthetic. | Clone() names an unregistered parent tmp_base_<file>. |
item.base is not guaranteed to be a registered ID; consumers that walk base must use BaseClass/the metatable, not stored[item.base]. |
| Registration re-runs on hot reload. | stored[id] is overwritten, not guarded. |
Re-including an item file replaces the table; anything holding a reference to the old table keeps the stale one. Consistent with AGENTS.md footgun #7. |
onPlace setter is lowercase. |
Preserved for compatibility. | item:onPlace(fn) sets the hook; item:OnPlace is nil. |
No validation at Register. |
By design (non-goal). | A missing name errors inside register's log-string concatenation rather than with a useful message; a missing uniqueID stores the item under nil. |
Consequences for contributors
- New items use
ITEM:New(<real base>)and chain setters; nevercityrp.item.registerfor new code (modern-apis.md). - Shared data between variants goes on an unregistered
local BASE = ITEM:New(...), and each variant isBASE:Clone():...:Register(id). - Do not create a registered item whose only job is inheritance. If a real base is missing, add it under
core/items/base/so the load order protects it. :Register(id)must be the last call in the chain; the post-processing incityrp.item.registerruns once and does not see later direct mutations (only lazily-chained ones, see above).- When touching a legacy file, converting it to this route is welcome but must preserve its
uniqueIDs — those are persisted in player inventories.