Select a Part or Model in Studio, answer "what is this object?", fill a form — it becomes a gatherable node, a mob or a quest giver. Closes the gap between authoring a def and setting up a world object, which until now meant knowing to tag a part and hand-typing PascalCase attributes in the property panel. Engine — components can declare an attribute SCHEMA: - src/components/Schema.luau (new): AttributeSpec/Display/ComponentSchema types, normalize/defaults/get/list, and the schemas for Gatherable, Mob, QuestGiver. Dependency-free ON PURPOSE: the plugin requires it live at edit time, and the component modules themselves can't be required there (Harvesting asserts IsServer; Remotes creates instances in ReplicatedStorage). - Components.define now accepts EITHER the legacy `attr = default` map or a schema array, normalizing both to one ordered spec list; bind() reads the derived default map, so binding is byte-identical. Legacy maps are sorted, as `pairs` order is arbitrary and would make a UI jitter. New getSchema/ listSchemas. The three shipped components pull name/tag/display/attributes from the schema; their onSetup bodies are untouched (defaults verified identical, all 23 attributes). Plugin — the Build page: - Field.luau (new): coerce/format/equalsDefault, lifted from ConfigAdmin (which now delegates), shared by every schema-driven editor. - FieldRow.luau (new): the shared [○/●] label … control + help row, including a ⌄ picker that cycles authored ids for fields declaring `ref`. - BuildAdmin.luau (new): live schema read with three distinct empty states, selection/eligibility/identify, deltas-only attribute writes, applyType (tag + clear any other component) and clear. - BuildAdminUi.luau (new): chooser cards, grouped form, multi-select apply, stale-bind-marker warning, SelectionChanged-driven refresh. - init.server.luau: record()-wrapped buildActions + the page. Docs: docs/admin-plugin.md Build section + a 60-second walkthrough, docs/extending.md schema guide, a CONTRIBUTING rule that new creator components declare one, CHANGELOG. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Extending SurvivorCore
SurvivorCore ships mechanics, not content. You bring the items, world, art, and rules; the engine wires up behavior. There are three ways to plug in, in rough order of how often you'll reach for them:
- Registries — register content from code
(
Items,Recipes,Stats,Mobs, …). - Components — tag your own objects and set Attributes; no engine-side definition needed.
- Hooks — react to engine lifecycle events to add game-specific flourish without forking the engine.
Two foundation services — Config and
Assets — support all three.
Everything below assumes you've required the engine and will call SurvivorCore.start()
once, from the server, after registering your content:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
1. Registries — register content from code
A registry is an empty table the engine owns and your game fills. Every registry shares the
same lifecycle — register a definition, it's validated and indexed by a key field, and you
query it back. The engine ships zero concrete entries.
SurvivorCore.Items.register({ id = "reed", name = "Reed", stack = 20 })
SurvivorCore.Items.register({ id = "reed_basket", name = "Reed Basket", stack = 1 })
SurvivorCore.Recipes.register({
id = "reed_basket",
station = "hand", -- "hand", "campfire", … — just a routing tag
ingredients = { { item = "reed", count = 5 } },
output = { item = "reed_basket", count = 1 },
})
The registries
| Registry | Key field | What it holds |
|---|---|---|
Items |
id |
Item definitions (name, stack size, …) — incl. weapons + ammo. |
Recipes |
id |
Crafting and cooking recipes — one registry, routed by station. |
Resources |
id |
Gatherable-resource defs (what a tagged node is). |
Stats |
name |
Survival/status stat models. |
Achievements |
key |
Achievement defs (counter + threshold — docs). |
Quests |
id |
Quest defs (objectives + rewards — docs). |
Codex |
id |
Discoverable lore / collectible entries. |
Appearance |
id |
Character appearance options. |
Mobs |
id |
Creature / hostile-mob definitions. |
Shared API (every registry)
local Items = SurvivorCore.Items
Items.register(def) -- add one; errors on missing/duplicate key
Items.registerMany({ a, b }) -- add several
Items.get("reed") -- fetch by key, or nil
Items.getAll() -- array of every def
Items.query(function(d) -- filtered array
return d.stack == 1
end)
Some registries add convenience helpers — e.g. recipes by station:
SurvivorCore.Recipes.forStation("campfire")
SurvivorCore.Stats.defineStat({ name = "Thirst", max = 100 }) -- alias of Stats.register
2. Components — tag your own objects
When the content is a Roblox object you built, you don't need a code-side definition at all. Tag your mesh with a CollectionService tag and set per-instance Attributes; the engine binds behavior to it.
The flagship component is Gatherable. Build any part/mesh, tag it Gatherable, and set:
| Attribute | Type | Meaning |
|---|---|---|
ItemId |
string | what it yields |
Yield |
number | amount per full harvest |
HP |
number | interactions to deplete |
That's it — the engine adds a ProximityPrompt and runs the harvest loop. Attributes can be set in Studio's Properties panel or from code; a future builder UI will set them visually.
Defining your own component
SurvivorCore.Components.define({
name = "Campfire",
tag = "Campfire",
attributes = { -- attribute name -> default value
FuelSeconds = 60,
Lit = false,
},
onSetup = function(instance, values)
-- `values` is the resolved attributes (instance value, else the default).
-- Wire up prompts, signals, etc. here.
end,
})
SurvivorCore.start() calls Components.scan() for you, which binds everything currently
tagged and keeps binding new instances as they appear. Each instance is bound once (guarded by
an internal _scBound attribute).
Attributes prefixed with _ are engine-internal (the _scBound bind marker, and any values a
component resolves and stashes for its server system). Never author them by hand.
Declaring a schema (so the Builder can render a form)
attributes also accepts a schema array, which adds the kind, label, help text and options for
each attribute. That's what lets the Studio plugin's Build page render a setup form for your
component — select a part, pick your component, fill the fields — with no UI code on your side:
SurvivorCore.Components.define({
name = "Campfire",
tag = "Campfire",
display = {
title = "Campfire",
summary = "A fire players light for warmth and cooking.",
instance = "any", -- or "Model" / "BasePart" — what the Builder will let you tag
},
attributes = {
{ attr = "FuelSeconds", kind = "number", label = "Fuel (seconds)", default = 60, min = 0,
help = "How long one load of fuel burns." },
{ attr = "Lit", kind = "boolean", label = "Starts lit", default = false },
},
onSetup = function(instance, values)
-- `values` is identical either way: the instance's attribute, else the default.
end,
})
Both forms bind identically — the shorthand map is still supported and nothing changes for
components that use it. Field kind is number | boolean | string | enum (with choices);
a field may also carry min/max/integer, a placeholder, a group (form section), and ref
(a content category whose authored ids the Builder offers as a picker). The engine's own components
declare theirs in src/components/Schema.luau.
Convention: every creator-facing component should declare a schema, so it is Builder-drivable rather than requiring the creator to know attribute names.
3. Hooks — react to engine lifecycle events
Hooks are where game-specific flourish lives outside the engine. Where a registry says "here is my content" and a component says "here is my object," a hook says "engine, when X happens, run my code." This is how The Counter Earth's trees physically fall and segment into logs while SurvivorCore itself ships none of that.
SurvivorCore.Hooks.on("gather:depleted", function(ctx)
-- ctx = { instance, player, values }
spawnFallingTreePhysics(ctx.instance) -- your game's flair
grantBonusDrop(ctx.player, ctx.values.ItemId)
end)
Hooks.on returns a disconnect function:
local disconnect = SurvivorCore.Hooks.on("gather:hit", function(ctx)
-- ctx = { instance, player, values, hpLeft }
flashOutline(ctx.instance)
end)
-- later: disconnect()
Engine systems fire hooks with Hooks.run("name", ctx). The full catalogue lives in the header of
src/foundation/Hooks.luau; highlights:
| Hook family | Fired by |
|---|---|
gather:hit / gather:depleted / gather:blocked |
harvesting (docs) |
craft:start / craft:end / craft:blocked |
crafting (docs) |
item:use · inventory:changed |
inventory (docs) |
mob:spawned / mob:hit / mob:attack / mob:died |
mobs & AI (docs) |
combat:hit / combat:kill |
combat (docs) |
quest:started / quest:progress / quest:completed / quest:blocked |
quests (docs) |
achievement:unlocked |
achievements (docs) |
player:died · lootbag:dropped / lootbag:collected |
death & loot bags (docs) |
trade:started / trade:completed |
player trading (docs) |
These gameplay events ALSO cross the EventBridge with the same names — that bus is what quests,
achievements, and analytics consume (via the Progression translation layer,
docs).
Hooks vs. EventBridge
- Hooks = "the engine is about to / just did X — creators, do your thing here." Scoped, lifecycle-shaped extension points.
- EventBridge = a semantic event bus for things that
happened (
animal_killed, …), which any number of decoupled subscribers (achievements, quests, analytics, a webhook) can observe:
local disconnect = SurvivorCore.Events.onFire(function(eventType, player, data)
if eventType == "animal_killed" then
analytics:track(player, "hunt", data)
end
end)
Config — tune the engine
The engine declares default tunables per section; your game overrides them via deep merge.
-- (engine declares defaults internally, e.g.)
-- Config.defineSection("Energy", { DrainPerSecond = 16, RegenPerSecond = 14 })
SurvivorCore.Config.override("Energy", { DrainPerSecond = 10 }) -- your tweak
SurvivorCore.Config.get("Energy.DrainPerSecond") -- 10
Full resolution order (each layer wins over the previous):
- Engine defaults —
defineSectionat require time. - Your game's
Config.override(...)— code-time tuning, any time beforestart(). - The
SurvivorCoreEngineConfiginstance — the no-code layer the admin plugin's Engine Config editor writes (deltas-only). Layered over everything as the first step ofstart()/startClient(), so it applies on the next Play. (SurvivalStatsConfigis its live-applied sibling for survival stats.)
Assets — keep IDs out of code
The engine never hardcodes asset IDs — your game registers them and the engine reads them back, with a safe empty-string fallback if one's missing. This is a hard rule for engine code and the right pattern for your content too.
SurvivorCore.Assets.register("Sounds", "Harvest", "rbxassetid://123456789")
SurvivorCore.Assets.registerCategory("Animations", {
Idle = "rbxassetid://111",
Chop = "rbxassetid://222",
})
SurvivorCore.Assets.get("Sounds", "Harvest") -- "rbxassetid://123456789"
Two engine-reserved categories resolve automatically in the built-in UI: StatIcons (per stat,
for the HUD) and ItemIcons (per item id, for the inventory). An item icon can also live inline on
the def's icon field — either works.
SurvivorCore.Assets.register("ItemIcons", "reed", "rbxassetid://…")
Inventory & UI
The engine ships a full inventory (slots + carry-weight), a hotbar, equipment slots, and a tabbed menu — all driven by the same template + binder model as the HUD, and extensible from code:
SurvivorCore.Inventory.add(player, "reed", 5) -- server: grant items
SurvivorCore.UI.registerPanel({ id = "map", title = "Map", build = fn }) -- client: add a tab
See Inventory, Hotbar & Menu UI for the item-def schema, the full API, the
RemoteEvent contract, the item:use / inventory:changed hooks, and the template attribute
conventions.
Putting it together
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
-- content
SurvivorCore.Items.register({ id = "reed", name = "Reed", stack = 20 })
SurvivorCore.Assets.register("Sounds", "Harvest", "rbxassetid://123456789")
-- flourish
SurvivorCore.Hooks.on("gather:depleted", function(ctx)
print(ctx.player.Name, "harvested", ctx.values.ItemId)
end)
-- go
SurvivorCore.start()
See Getting Started to wire the engine into your place, Survival Stats + HUD for the built-in stats and HUD, and Architecture for how the layers fit together.