Files
SurvivorCore/docs/extending.md
T
Samuel LisonandClaude Opus 4.8 177a4118fe feat(trade): secure player-to-player trading (#15)
A new server-authoritative, dupe-proof trade system. Walk up to another player,
trigger the "Trade" prompt; they Accept/Decline; both stage loose backpack
stacks (drag from the inventory grid, with −/+ qty steppers) and must Confirm
before anything moves.

Anti-dupe by construction: staging is by-reference (items never leave the owner
until commit), and the commit is one synchronous, no-yield critical section —
re-validate holds → pre-flight both receivers have room (new Inventory.canAccept)
→ remove both → grant with addUpTo → refund any residue. Item count is conserved
on every branch (verified: 200k-iteration conservation + fit-oracle fuzz).

Auto-cancels on death / leave / out-of-range (MaxDistance) / request timeout; a
staging change resets both confirms. New Trade server system + TradeUi client
window, the "Trading" Config section (tunable in SurvivorCore Studio), hooks
trade:started / trade:completed, and a trades_total progression counter. v1 is
backpack stacks only (worn gear reserved behind AllowEquippedItems).

- src/systems/Trade.luau, src/client/TradeUi.luau, src/shared/TradingConfig.luau (new)
- src/systems/Inventory.luau: exported canAccept (weight+slot fit oracle)
- src/shared/EngineConfig.luau: Trading section; src/init.luau boot wiring
- docs/trading.md, README, CHANGELOG (Unreleased), Hooks catalogue, extending.md
- demo/server/TradeTestStation.server.luau: /tradetest 2-player conservation harness

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 20:19:06 +10:00

10 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:

  1. Registries — register content from code (Items, Recipes, Stats, Mobs, …).
  2. Components — tag your own objects and set Attributes; no engine-side definition needed.
  3. 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).


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):

  1. Engine defaultsdefineSection at require time.
  2. Your game's Config.override(...) — code-time tuning, any time before start().
  3. The SurvivorCoreEngineConfig instance — the no-code layer the admin plugin's Engine Config editor writes (deltas-only). Layered over everything as the first step of start()/startClient(), so it applies on the next Play. (SurvivalStatsConfig is 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.