Scaffold SurvivorCore foundation (v0.1.0)

Foundation (Config/Assets/EventBridge/Hooks/Registry), content registry family, creator-owned component layer + Gatherable example, runnable demo place, Rojo/Wally/Rokit config, and release CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Samuel Lison
2026-06-18 16:30:32 +10:00
co-authored by Claude Opus 4.8
commit 286e2f5f91
19 changed files with 723 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
name: Release
# Build the drop-in .rbxm from source on every version tag and attach it to the release.
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install toolchain (rojo via rokit)
uses: CompeyDev/setup-rokit@v0.1.2
- name: Build engine model
run: rojo build default.project.json --output SurvivorCore.rbxm
- name: Attach to release
uses: softprops/action-gh-release@v2
with:
files: SurvivorCore.rbxm
+23
View File
@@ -0,0 +1,23 @@
# Rojo / Roblox
sourcemap.json
*.rbxl
*.rbxlx
*.rbxl.lock
*.rbxlx.lock
# Build output (the .rbxm is built in CI and attached to releases)
*.rbxm
*.rbxmx
/build/
# Toolchain managers
/.rokit/
# Wally
/Packages/
/ServerPackages/
/DevPackages/
# Editor / OS
.DS_Store
.vscode/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Samuel Lison
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+80
View File
@@ -0,0 +1,80 @@
<div align="center">
# SurvivorCore
**A batteries-included, creator-extensible survival game framework for Roblox.**
</div>
> ⚠️ **Status: early scaffold (v0.1.0).** The foundation layer and architecture are in
> place; survival systems are being extracted from a production game ([The Counter
> Earth](https://github.com/TemujinCalidius/TheCounterEarth)) into this engine. APIs will
> change. Not yet production-ready.
## What it is
SurvivorCore gives you the *mechanics* of a survival game — stats (energy/hunger/thirst…),
inventory, crafting, cooking, harvesting, hostile mobs & combat, achievements, a codex, and
player persistence — without dictating your *content*. You bring your own items, world, and
art; the engine wires up the behavior.
## Two ways to extend it
**1. Programmatic — register content from code:**
```lua
local SurvivorCore = require(ReplicatedStorage.SurvivorCore)
SurvivorCore.Items.register({ id = "reed", name = "Reed", stack = 20 })
SurvivorCore.Recipes.register({
id = "reed_basket", station = "hand",
ingredients = { { item = "reed", count = 5 } },
output = { item = "reed_basket", count = 1 },
})
SurvivorCore.start()
```
**2. Creator-owned — attach behavior to your own objects:**
Build *any* mesh, tag it `Gatherable`, and set attributes — no engine-side definition needed:
| Attribute | Meaning |
|---|---|
| `ItemId` | what it yields |
| `Yield` | amount per full harvest |
| `HP` | hits to deplete |
The same pattern extends to craftables, huntables, farmables, and more. For advanced,
game-specific behavior (e.g. trees that physically fall and cut into pieces), hook into
engine lifecycle events instead of forking the engine:
```lua
SurvivorCore.Hooks.on("gather:depleted", function(ctx)
-- your custom drop / VFX / physics here
end)
```
## Getting started
- **Developers** — consume via [Rojo](https://rojo.space) (clone + `rojo serve`) or, once
published, as a [Wally](https://wally.run) package.
- **Drop-in** — grab `SurvivorCore.rbxm` from a [Release](../../releases) (auto-built from
source via `rojo build`), drop it into `ReplicatedStorage`, and add a short bootstrap
script that `require`s it and calls `.start()`.
## Project layout
```
src/
init.luau -- SurvivorCore root: .start() + the public API
foundation/ -- Config, Assets, EventBridge, Hooks, Registry (the core plumbing)
registries/ -- Items, Recipes, Stats, Achievements, Codex, Appearance, Mobs
components/ -- creator-facing tag/attribute components (e.g. Gatherable)
demo/ -- a runnable demo place that consumes the engine
docs/architecture.md -- how the layers fit together
```
## License
[MIT](LICENSE) © 2026 Samuel Lison
+6
View File
@@ -0,0 +1,6 @@
{
"name": "SurvivorCore",
"tree": {
"$path": "src"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "SurvivorCore Demo",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"SurvivorCore": {
"$path": "src"
}
},
"ServerScriptService": {
"Demo": {
"$path": "demo/server"
}
},
"Workspace": {
"$ignoreUnknownInstances": true
}
}
}
+30
View File
@@ -0,0 +1,30 @@
--[[
Demo boot script. Shows both extension layers:
1. registering content from code, and
2. reacting to creator-owned components via hooks.
Try it: serve `demo.project.json` into a place, add a Part to Workspace, tag it
"Gatherable" (CollectionService), set attributes ItemId="reed", Yield=2, HP=3,
then play and interact with it.
]]
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local SurvivorCore = require(ReplicatedStorage:WaitForChild("SurvivorCore"))
-- 1. Programmatic content
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",
ingredients = { { item = "reed", count = 5 } },
output = { item = "reed_basket", count = 1 },
})
-- 2. React to creator-owned Gatherables
SurvivorCore.Hooks.on("gather:depleted", function(ctx)
print(("[demo] %s fully gathered %s"):format(ctx.player.Name, tostring(ctx.values.ItemId)))
end)
SurvivorCore.start()
print("SurvivorCore demo booted — v" .. SurvivorCore.VERSION)
+52
View File
@@ -0,0 +1,52 @@
# SurvivorCore — Architecture
> Companion to the boundary map in the TheCounterEarth repo
> (`docs/survivorcore-boundary-map.md`), which tracks what is being extracted from the
> production game into this engine.
## The shape
SurvivorCore is an **engine of mechanics, not content**. It exposes two extension layers
over a small foundation.
```
┌─────────────────────────────────────────────┐
Creators → │ Component layer (tag + attributes + UI) │ "I own the object"
├─────────────────────────────────────────────┤
Developers → │ Registry layer (register() from code) │ "I own the data"
├─────────────────────────────────────────────┤
│ Foundation: Config · Assets · EventBridge · │
│ Hooks · Registry │
└─────────────────────────────────────────────┘
```
### Foundation
- **Config** — engine ships default tunables per section; games override via deep merge.
- **Assets** — typed asset-id registry; the engine never hardcodes ids.
- **EventBridge** — semantic event bus (`fire`/`onFire`); subscribers decouple from sources.
- **Hooks** — lifecycle extension points (`Hooks.on("craft:start", ...)`).
- **Registry** — the shared register/validate/index/query lifecycle behind every registry.
### Registry layer (developers)
Empty registries the game populates at startup: `Items`, `Recipes` (crafting + cooking are
one registry routed by `station`), `Stats`, `Achievements`, `Codex`, `Appearance`, `Mobs`.
### Component layer (creators)
Behaviors bound to a CollectionService tag, configured by per-instance Attributes. Creators
tag their **own** meshes (`Gatherable`, and later craftable/huntable/farmable/station
components) and fill in values — optionally through a builder UI. No engine-side definition
required.
## Why hooks instead of baked-in behavior
Game-specific flourish stays out of the engine. TheCounterEarth's trees physically fall and
segment into logs — SurvivorCore will **not** ship that. Instead it fires
`gather:hit` / `gather:depleted` (and similar) hooks; a creator (including TheCounterEarth
itself, going forward) implements the felling physics in their own hook handler. The engine
stays small and universal; creativity lives at the edges.
## Status & roadmap
v0.1.0 is the foundation scaffold. Extraction order (from the boundary map): foundations →
pure-core lift → registries → SPLIT server systems (incl. the mob/combat cluster) → UI
layer → harvesting/wildlife sub-engine.
+4
View File
@@ -0,0 +1,4 @@
# Toolchain pins (https://github.com/rojo-rbx/rokit). Run `rokit install`.
[tools]
rojo = "rojo-rbx/rojo@7.6.1"
wally = "UpliftGames/wally@0.3.2"
+55
View File
@@ -0,0 +1,55 @@
--[[
Gatherable the flagship creator-owned component.
A creator builds ANY part/mesh, tags it "Gatherable", and sets attributes:
ItemId (string) what it yields
Yield (number) amount per full harvest
HP (number) interactions to deplete
No engine-side content needed: the creator owns the object. Advanced behavior
(custom drops, VFX, falling physics) attaches via Hooks rather than editing core.
]]
local Components = require(script.Parent)
local Hooks = require(script.Parent.Parent.foundation.Hooks)
return Components.define({
name = "Gatherable",
tag = "Gatherable",
attributes = {
ItemId = "unknown",
Yield = 1,
HP = 3,
},
onSetup = function(instance, values)
instance:SetAttribute("_HP", values.HP)
local host = if instance:IsA("BasePart")
then instance
else instance:FindFirstChildWhichIsA("BasePart")
if not host then
warn(`[Gatherable] '{instance:GetFullName()}' has no BasePart to host a prompt`)
return
end
local prompt = Instance.new("ProximityPrompt")
prompt.ActionText = "Gather"
prompt.ObjectText = tostring(values.ItemId)
prompt.HoldDuration = 0.4
prompt.Parent = host
prompt.Triggered:Connect(function(player)
local hp = (instance:GetAttribute("_HP") or 1) - 1
instance:SetAttribute("_HP", hp)
-- TODO (extraction): grant `values.Yield` of `values.ItemId` via the
-- inventory service once persistence/inventory are extracted.
Hooks.run("gather:hit", { instance = instance, player = player, values = values, hpLeft = hp })
if hp <= 0 then
Hooks.run("gather:depleted", { instance = instance, player = player, values = values })
instance:Destroy()
end
end)
end,
})
+69
View File
@@ -0,0 +1,69 @@
--[[
Components the creator-facing layer. A component binds engine behavior to a
CollectionService tag, reading per-instance Attributes (with defaults). Creators
tag their OWN objects and fill in attributes; no engine-side definition required.
Components.define({
name = "Gatherable",
tag = "Gatherable",
attributes = { ItemId = "unknown", Yield = 1, HP = 3 },
onSetup = function(instance, values) ... end,
})
Call Components.scan() once (SurvivorCore.start does this) to bind existing tagged
instances and watch for new ones. A future builder UI can set these attributes
visually instead of by hand.
]]
local CollectionService = game:GetService("CollectionService")
local Components = {}
export type Spec = {
name: string,
tag: string,
attributes: { [string]: any }?, -- attribute name -> default value
onSetup: ((instance: Instance, values: { [string]: any }) -> ())?,
}
local defined: { [string]: Spec } = {}
function Components.define(spec: Spec): Spec
assert(spec.name and spec.tag, "Component requires `name` and `tag`")
assert(defined[spec.name] == nil, `Component '{spec.name}' already defined`)
defined[spec.name] = spec
return spec
end
local function readAttributes(instance: Instance, attributes: { [string]: any }?)
local values = {}
for attrName, default in attributes or {} do
local v = instance:GetAttribute(attrName)
values[attrName] = if v == nil then default else v
end
return values
end
local function bind(instance: Instance, spec: Spec)
if instance:GetAttribute("_scBound") then
return
end
instance:SetAttribute("_scBound", true)
if spec.onSetup then
spec.onSetup(instance, readAttributes(instance, spec.attributes))
end
end
-- Bind everything currently tagged, then keep binding new instances as they appear.
function Components.scan()
for _, spec in defined do
for _, instance in CollectionService:GetTagged(spec.tag) do
task.spawn(bind, instance, spec)
end
CollectionService:GetInstanceAddedSignal(spec.tag):Connect(function(instance)
bind(instance, spec)
end)
end
end
return Components
+34
View File
@@ -0,0 +1,34 @@
--[[
Assets typed asset-id lookup. The engine NEVER hardcodes asset ids; games
register theirs and the engine reads them back, with a safe fallback.
Assets.register("Sounds", "Harvest", "rbxassetid://123")
Assets.get("Sounds", "Harvest") -- "rbxassetid://123", or "" + warning if missing
]]
local Assets = {}
local store: { [string]: { [string]: string } } = {}
function Assets.register(category: string, key: string, assetId: string)
store[category] = store[category] or {}
store[category][key] = assetId
end
function Assets.registerCategory(category: string, map: { [string]: string })
for key, id in map do
Assets.register(category, key, id)
end
end
function Assets.get(category: string, key: string): string
local cat = store[category]
local id = cat and cat[key]
if not id or id == "" then
warn(`[SurvivorCore.Assets] missing asset '{category}.{key}' — using fallback`)
return ""
end
return id
end
return Assets
+58
View File
@@ -0,0 +1,58 @@
--[[
Config engine ships default tuning per section; games override via deep merge.
Config.defineSection("Energy", { DrainPerSecond = 16, RegenPerSecond = 14 })
Config.override("Energy", { DrainPerSecond = 10 }) -- game tweak
Config.get("Energy.DrainPerSecond") -- 10
]]
local Config = {}
local sections: { [string]: any } = {}
local function deepCopy(t: any): any
if typeof(t) ~= "table" then
return t
end
local c = {}
for k, v in t do
c[k] = deepCopy(v)
end
return c
end
local function deepMerge(base: any, over: any)
for k, v in over do
if typeof(v) == "table" and typeof(base[k]) == "table" then
deepMerge(base[k], v)
else
base[k] = deepCopy(v)
end
end
end
-- Engine code calls this to declare a section with its default values.
function Config.defineSection(name: string, defaults: { [string]: any })
assert(sections[name] == nil, `Config section '{name}' already defined`)
sections[name] = deepCopy(defaults)
end
-- Game code calls this to override engine defaults.
function Config.override(name: string, overrides: { [string]: any })
assert(sections[name] ~= nil, `Config section '{name}' not defined`)
deepMerge(sections[name], overrides)
end
-- Dotted lookup: Config.get("Energy.DrainPerSecond")
function Config.get(path: string): any
local node: any = sections
for part in string.gmatch(path, "[^.]+") do
if typeof(node) ~= "table" then
return nil
end
node = node[part]
end
return node
end
return Config
+34
View File
@@ -0,0 +1,34 @@
--[[
EventBridge the engine's event bus. Systems fire semantic events; anything
(achievements, quests, analytics, an external webhook) can subscribe with zero
changes to the firing code.
local disconnect = EventBridge.onFire(function(eventType, player, data) ... end)
EventBridge.fire("animal_killed", player, { species = "deer" })
]]
local EventBridge = {}
type Listener = (eventType: string, player: Player?, data: { [string]: any }) -> ()
local listeners: { Listener } = {}
function EventBridge.onFire(callback: Listener): () -> ()
table.insert(listeners, callback)
return function()
local i = table.find(listeners, callback)
if i then
table.remove(listeners, i)
end
end
end
function EventBridge.fire(eventType: string, player: Player?, data: { [string]: any }?)
local payload = data or {}
for _, cb in listeners do
task.spawn(cb, eventType, player, payload)
end
-- TODO (extraction): optional HTTP forwarding + batched queue, endpoint from Config.
end
return EventBridge
+42
View File
@@ -0,0 +1,42 @@
--[[
Hooks lifecycle extension points. Where EventBridge is "something happened",
Hooks is "the engine is about to / just did X — creators, do your thing here".
Hooks.on("craft:start", function(ctx) ctx.station:lightFire() end)
Hooks.on("craft:end", function(ctx) ringBell(ctx.station) end)
-- inside the engine:
Hooks.run("craft:start", { station = station, recipe = recipe, player = player })
This is how game-specific flourish (tree-felling physics, station VFX, custom drops)
stays OUT of the engine while remaining first-class.
]]
local Hooks = {}
type Hook = (...any) -> ()
local hooks: { [string]: { Hook } } = {}
function Hooks.on(name: string, callback: Hook): () -> ()
hooks[name] = hooks[name] or {}
table.insert(hooks[name], callback)
return function()
local list = hooks[name]
local i = list and table.find(list, callback)
if i then
table.remove(list, i)
end
end
end
function Hooks.run(name: string, ...)
local list = hooks[name]
if not list then
return
end
for _, cb in list do
task.spawn(cb, ...)
end
end
return Hooks
+69
View File
@@ -0,0 +1,69 @@
--[[
Registry the shared lifecycle behind every content registry: register a def,
validate it, index it by a key, and query it. Returns a plain table of functions
so the public API is dot-callable: SurvivorCore.Items.register({ ... }).
local Items = Registry.new("Items", { keyField = "id" })
Items.register({ id = "reed", name = "Reed" })
Items.get("reed")
]]
local Registry = {}
export type Options = {
keyField: string?, -- unique id field (default "id")
validate: ((def: any) -> (boolean, string?))?,
}
function Registry.new(name: string, options: Options?)
local opts = options or {}
local keyField = opts.keyField or "id"
local validate = opts.validate
local byId: { [any]: any } = {}
local all: { any } = {}
local self = {}
self.name = name
function self.register(def: any): any
local key = def[keyField]
assert(key ~= nil, `[{name}] def is missing key field '{keyField}'`)
assert(byId[key] == nil, `[{name}] '{tostring(key)}' is already registered`)
if validate then
local ok, err = validate(def)
assert(ok, `[{name}] '{tostring(key)}' is invalid: {err or "?"}`)
end
byId[key] = def
table.insert(all, def)
return def
end
function self.registerMany(defs: { any })
for _, def in defs do
self.register(def)
end
end
function self.get(key: any): any
return byId[key]
end
function self.getAll(): { any }
return all
end
function self.query(predicate: (def: any) -> boolean): { any }
local out = {}
for _, def in all do
if predicate(def) then
table.insert(out, def)
end
end
return out
end
return self
end
return Registry
+58
View File
@@ -0,0 +1,58 @@
--[[
SurvivorCore root module.
local SurvivorCore = require(ReplicatedStorage.SurvivorCore)
SurvivorCore.Items.register({ id = "reed", name = "Reed" })
SurvivorCore.start()
Two extension layers:
Programmatic registries (Items, Recipes, Stats, Mobs, ...) register content from code.
Creator components (tag your own object "Gatherable", set attributes) + lifecycle Hooks.
]]
local Config = require(script.foundation.Config)
local Assets = require(script.foundation.Assets)
local EventBridge = require(script.foundation.EventBridge)
local Hooks = require(script.foundation.Hooks)
local Registries = require(script.registries)
local Components = require(script.components)
local SurvivorCore = {}
SurvivorCore.VERSION = "0.1.0"
-- Foundation
SurvivorCore.Config = Config
SurvivorCore.Assets = Assets
SurvivorCore.Events = EventBridge
SurvivorCore.Hooks = Hooks
-- Content registries
SurvivorCore.Items = Registries.Items
SurvivorCore.Recipes = Registries.Recipes
SurvivorCore.Stats = Registries.Stats
SurvivorCore.Achievements = Registries.Achievements
SurvivorCore.Codex = Registries.Codex
SurvivorCore.Appearance = Registries.Appearance
SurvivorCore.Mobs = Registries.Mobs
-- Creator-facing component layer
SurvivorCore.Components = Components
local started = false
-- Boot the engine. Call once, from the server, after registering content.
function SurvivorCore.start(_options: { [string]: any }?)
assert(not started, "SurvivorCore.start() called twice")
started = true
-- Load built-in components so their tags are recognised.
require(script.components.Gatherable)
-- TODO (extraction): boot order — Config merge → Assets → persistence → systems.
Components.scan()
return SurvivorCore
end
return SurvivorCore
+31
View File
@@ -0,0 +1,31 @@
--[[
The content registry family. Each is an empty registry the game populates at startup;
the engine ships zero concrete items/recipes/lore/ids.
NOTE: Crafting and cooking are ONE registry `station` ("hand", "campfire", ...) is
just a routing tag, per the boundary-map decision.
]]
local Registry = require(script.Parent.foundation.Registry)
local Registries = {}
Registries.Items = Registry.new("Items", { keyField = "id" })
Registries.Recipes = Registry.new("Recipes", { keyField = "id" })
Registries.Stats = Registry.new("Stats", { keyField = "name" })
Registries.Achievements = Registry.new("Achievements", { keyField = "key" })
Registries.Codex = Registry.new("Codex", { keyField = "id" })
Registries.Appearance = Registry.new("Appearance", { keyField = "id" })
Registries.Mobs = Registry.new("Mobs", { keyField = "id" })
-- Documented alias: Stats.defineStat(model) == Stats.register(model)
Registries.Stats.defineStat = Registries.Stats.register
-- Convenience: all recipes for a given station.
function Registries.Recipes.forStation(station: string)
return Registries.Recipes.query(function(r)
return r.station == station
end)
end
return Registries
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "temujincalidius/survivorcore"
description = "Batteries-included, creator-extensible survival game framework for Roblox."
version = "0.1.0"
license = "MIT"
authors = ["Samuel Lison"]
registry = "https://github.com/UpliftGames/wally-index"
realm = "shared"
exclude = ["demo", "docs", "*.project.json"]
[dependencies]