{"id":"luminalabsgames/nova","name":"nova","scope":"luminalabsgames","platform":"roblox","description":"Shard v2: one boot order, isolated failures, live feature flags, a typed event bus and buffer-packed networking","version":"0.1.0","latest":"0.1.0","versions":["0.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{"sleitnick/trove":{"version":"^1.8.0","alias":"Trove"}},"integrity":"7e2c9c66dc03475590475213a71558c02294fe1f0d6d67cb898ca8e2d07c8467","likes":0,"downloads":0,"install":"forest install luminalabsgames/nova","url":"https://forest.dev/p/roblox/luminalabsgames/nova","files":"https://api.forest.dev/ai/package/roblox/luminalabsgames/nova/files","readme":"# Nova\r\n\r\nShard v2. Nova keeps the Shard features our games actually use (the builder, `get`, per-module\r\nlogs, dependencies, cleanup, signals, config, feature flags) and cuts the rest. It adds a boot that\r\none broken module cannot take down, live feature flags, an event bus with real connections, and a\r\ntyped, buffer-packed network layer.\r\n\r\n```toml\r\n# wally.toml\r\n[dependencies]\r\nNova = \"luminalabsgames/nova@0.1.0\"\r\n```\r\n\r\nNova's only dependency is `sleitnick/trove`.\r\n\r\n## A game's layout\r\n\r\n```\r\nReplicatedStorage/Shared/    Packets.luau, Events.luau, Flags.luau\r\nServerScriptService/         Boot.server.luau, Services/\r\nStarterPlayerScripts/        Boot.client.luau, Controllers/\r\n```\r\n\r\n```lua\r\n-- ServerScriptService/Boot.server.luau\r\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\r\nlocal Nova = require(ReplicatedStorage.Packages.Nova)\r\n\r\nNova.boot({\r\n\tmodules = script.Parent.Services,\r\n\tflags = ReplicatedStorage.Shared.Flags,\r\n\tbefore = function()\r\n\t\tData:initialize() -- the data layer stays game code, and runs before any init\r\n\tend,\r\n})\r\n\r\n-- StarterPlayerScripts/Boot.client.luau\r\nNova.boot({\r\n\tmodules = script.Parent.Controllers,\r\n\tflags = ReplicatedStorage.Shared.Flags,\r\n\twaitForServer = 60,\r\n\tafter = function()\r\n\t\tReplicaClient.RequestData()\r\n\tend,\r\n})\r\n```\r\n\r\nControllers live in StarterPlayerScripts from the start, so their path is the same in edit mode\r\nand in a playtest. `modules` takes a folder or a list of folders. Nova requires every ModuleScript\r\nunder it, but not the children of a ModuleScript, which belong to that module.\r\n\r\n## Modules\r\n\r\n```lua\r\nlocal CombatService = Nova.new(\"CombatService\")\r\n\t:withDependencies({ \"InventoryService\" })\r\n\t:withSignals({ \"ShotFired\" })\r\n\t:withFlags({ NewRecoil = false })\r\n\t:withConfig({ Range = 500 })\r\n\t:withCleanup(function(self) ... end)\r\n\t:withLifecycle({\r\n\t\tinit = function(self)\r\n\t\t\tself.inventory = Nova.get(\"InventoryService\") -- allowed: declared above\r\n\t\tend,\r\n\t\tstart = function(self)\r\n\t\t\tPackets.Fire:listen(function(player, shot) ... end)\r\n\t\tend,\r\n\t})\r\n```\r\n\r\n`Nova.new` makes a service on the server and a controller on the client. You can also declare the\r\nphases as plain `function CombatService:init()` and `:start()` methods.\r\n\r\n| On the module | |\r\n| --- | --- |\r\n| `self.name`, `self.log`, `self.trove` | The only fields Nova adds. Its own state lives under `_nova`, so game fields cannot shadow it. |\r\n| `:getSignal(name)` | Returns a signal from `withSignals`. A misspelt name errors and suggests the closest one. |\r\n| `:flag(name, player?)` | Returns a flag's value (see Flags). |\r\n| `:getConfig(key, default?)` | Returns a value from `withConfig`. A missing key errors unless you pass a default. |\r\n| `:withCleanup(fn \\| thing)` | Adds to `self.trove`. A function is called with `self`. |\r\n| `:destroy()` | Cleans the trove and disconnects the signals. Nova calls this on a module that fails to boot. |\r\n\r\n| Nova | |\r\n| --- | --- |\r\n| `Nova.get(name)` | Never returns nil. An unknown name errors and suggests the closest names. During init it returns only the caller's dependencies (and theirs), and a module that failed to boot errors. |\r\n| `Nova.await(name, timeout?)` | For code outside a module. Yields on a signal until `get` can answer, and errors after `timeout` seconds (30 by default). |\r\n| `Nova.report()` | Returns what failed, what was skipped, the timing of each phase, the slowest phase and the server's status. |\r\n| `Nova.log`, `Nova.logger(name)`, `Nova.setLogLevel(level)` | Logging for code outside a module. |\r\n\r\n## Boot\r\n\r\n1. **One order for both phases.** Modules are sorted by `withDependencies`, with ties broken by\r\n   name, and `start` follows that order too. Shard ordered `init` only.\r\n2. **`init` must not yield.** A yielding init is failed and named. `WaitForChild`, DataStore calls\r\n   and anything else that waits belong in `start`.\r\n3. **Each `start` runs on its own thread, in order.** A start that waits holds up nobody. If you\r\n   need something another module makes in its start, wait for that thing, not for the module.\r\n4. **One failure costs one module** (CarSteal's BootGuard, built in). A module that fails to load,\r\n   or throws in init or start, is logged with a traceback and marked failed. Modules that declared\r\n   it as a dependency are skipped, and everything else boots.\r\n5. **Stalls are named.** A require, hook or start still running after 10 seconds\r\n   (`stallWarning`) is named in the log.\r\n6. **Mistakes stop the boot before any init runs:** two modules with one name (the duplicate\r\n   ModuleScript trap), a dependency that does not exist, or a dependency cycle (printed `A > B > A`).\r\n7. **The client never waits forever.** Once every start has returned, the server sets\r\n   `NovaServerReady` on Workspace (and `NovaServerFailed` naming any failures). The client waits up\r\n   to `waitForServer` seconds for it, then boots anyway and says why. It then waits up to 10 more\r\n   seconds for this player's flags to settle (see Flags).\r\n\r\nFor a join screen, these attributes are on Workspace: `NovaServerReady`, `NovaServerFailed`, and\r\non the client only, `NovaClientReady`, `NovaClientFailed`, `NovaBootSlowest` and\r\n`NovaBootSlowestSeconds`. They are on Workspace because attributes the server sets on\r\nReplicatedFirst never reach clients.\r\n\r\n## Flags\r\n\r\n```lua\r\n-- ReplicatedStorage/Shared/Flags.luau: every flag in the game\r\nreturn {\r\n\tNewRecoil = false,\r\n\tStreakDisplay = \"notification\",                -- \"badge\" | \"notification\"\r\n\tSkipIntro = { default = false, studio = true }, -- a different value in Studio playtests\r\n}\r\n\r\nif self:flag(\"NewRecoil\") then ... end\r\nNova.flags.observe(\"StreakDisplay\", function(value) ... end) -- runs now, then on every change\r\n```\r\n\r\nA flag's value comes from these sources, highest first:\r\n\r\n1. **One player:** a `Flag_<Name>` attribute on the Player. The server writes it from\r\n   `Nova.flags.setProvider(fn(name, player))`, for example an A/B group, or from\r\n   `Nova.flags.setFor(player, name, value)`, for example an admin command.\r\n2. **The whole server, live:** an attribute on `ReplicatedStorage.NovaFlags`. The server creates\r\n   this object at boot with every flag on it, so the Properties panel lists them all, and\r\n   `Nova.flags.set(name, value)` flips one. `set(name, nil)` puts it back to its default.\r\n3. **The default** in Flags.luau, or a module's `withFlags` when Flags.luau lacks the flag. When\r\n   both give a value and they differ, boot warns and Flags.luau wins.\r\n\r\nClients read flags and never write them. Only a flag the server knows (Flags.luau, or a service's\r\n`withFlags`) can be flipped live. A flag that only a controller declares is a fixed default, and\r\nthe client boot warns about it. A flag is a boolean, number or string, and a value of the\r\nwrong type is ignored with a warning. A name nobody declared errors (`flag(\"NewRecoli\")`) instead\r\nof reading as off. If a `NovaFlags` object is saved in the place, its values override the defaults\r\nfor the whole place, and the boot log lists every flag it changes.\r\n\r\n**Debug logs:** the built-in `NovaDebug` flag holds a list of module names (`\"CombatService,\r\nInventoryService\"`, or `\"*\"` for all). `self.log:debug(...)` lines from those modules print. Like\r\nany flag you can flip it live, or set it per player to see one tester's client logs. This replaces\r\n1.0.8's `debugMode` flag, which each service had to declare.\r\n\r\n## Bus\r\n\r\nEvents and states shared by a whole side (server or client). It replaces Dispatcher.\r\n\r\n```lua\r\n-- ReplicatedStorage/Shared/Events.luau\r\nreturn Nova.bus.define({\r\n\tPageOpened = Nova.bus.event() :: Nova.Event<string>,\r\n\tShowcasing = Nova.bus.state(false),\r\n})\r\n\r\nlocal connection = Events.PageOpened:Connect(function(page) ... end)\r\nconnection:Disconnect()              -- removes this listener and no other\r\nEvents.PageOpened:Fire(\"Inventory\")\r\nEvents.Showcasing:set(true)\r\nEvents.Showcasing:observe(function(on) ... end) -- runs now with the current value, then on change\r\n```\r\n\r\nEvery listener runs on its own thread, straight away and in order, so one that throws or yields\r\naffects nobody else. Events have no return values: to ask another module something, call it. A\r\nmisspelt event errors.\r\n\r\n## Net\r\n\r\n```lua\r\n-- ReplicatedStorage/Shared/Packets.luau, required by both sides\r\nlocal Net = require(ReplicatedStorage.Packages.Nova).Net\r\nlocal t = Net.types\r\n\r\nreturn Net.define({\r\n\tFire = Net.packet({\r\n\t\tfrom = \"client\",\r\n\t\tdata = t.struct({ weapon = t.u8, origin = t.vector3, direction = t.unitVector, shotTime = t.f64 }),\r\n\t\trate = 20, -- per player, per second\r\n\t}),\r\n\tAim = Net.packet({\r\n\t\tfrom = \"client\", reliable = false, latestOnly = true,\r\n\t\tdata = t.struct({ pitch = t.angle, yaw = t.angle }), rate = 60,\r\n\t}),\r\n\tHitConfirm = Net.packet({\r\n\t\tfrom = \"server\",\r\n\t\tdata = t.struct({ target = t.instance(\"Model\"), damage = t.u16, headshot = t.bool }),\r\n\t}),\r\n\tCraftItem = Net.request({\r\n\t\tdata = t.struct({ recipe = t.u16 }),\r\n\t\treply = t.struct({ ok = t.bool, reason = t.optional(t.string(64)) }),\r\n\t\trate = 5, timeout = 10,\r\n\t}),\r\n})\r\n```\r\n\r\n```lua\r\n-- client\r\nPackets.Fire:send(shot)\r\nPackets.HitConfirm:listen(function(hit) ... end)\r\nlocal reply = Packets.CraftItem:invoke({ recipe = 12 }) -- yields; errors on failure or timeout\r\n\r\n-- server\r\nPackets.Fire:listen(function(player, shot) ... end)\r\nPackets.HitConfirm:sendTo(player, hit) -- also sendAll, sendExcept, sendList, sendFilter, sendNear\r\nPackets.CraftItem:handle(function(player, request) return { ok = true } end)\r\nNova.Net.onAbuse(function(player, packetName, reason) ... end)\r\n```\r\n\r\n| Type | Bytes | Notes |\r\n| --- | --- | --- |\r\n| `u8 u16 u32`, `i8 i16 i32` | 1, 2, 4 | Out-of-range values error on send |\r\n| `f32 f64` | 4, 8 | NaN and infinity are rejected |\r\n| `bool` | 1 | |\r\n| `string(max)` | 1–2 + length | Longer than `max` errors on send. The server also rejects invalid UTF-8 |\r\n| `vector3` | 12 | |\r\n| `unitVector` | 4 | Octahedral encoding, worst error under 0.01° |\r\n| `angle` | 2 | Radians in 0.0055° steps, decoded to (−π, π] |\r\n| `cframe` | 19 | Position plus a smallest-three quaternion, worst rotation error under 0.01° |\r\n| `instance(class?)` | 0 | Sent beside the buffer and read back in order. Can arrive nil on a client that has not streamed it in |\r\n| `enum({ ... })` | 1 | A list of strings, sent as its index |\r\n| `optional(T)` | 1 + T | |\r\n| `array(T, max)` | 1–2 + n × T | |\r\n| `struct({ ... })` | sum of fields | Field names are never sent, and a field the struct lacks errors on send |\r\n\r\nAny number type takes a range that the server enforces, for example `t.u8({ max = 5 })` or\r\n`t.f32({ min = 0, max = 1 })`.\r\n\r\n- **Two remotes in total**, one reliable and one unreliable, created under the package. Each side\r\n  sends one call per channel per frame, so reliable packets keep their order. A frame's reliable\r\n  batch is split at 64 KB, the most the server accepts in one call. `Net.define` refuses a client\r\n  packet that could never fit.\r\n- **Packet ids** are the sorted names (1 byte, or 2 past 255 packets). A game has exactly one\r\n  `Net.define`.\r\n- **Unreliable batches** are flushed early to stay under 900 bytes (Roblox drops unreliable calls\r\n  over 1,000). A packet that could never fit is an error at `Net.define`. `latestOnly` adds a\r\n  2-byte sequence number, and the receiver drops anything older than the newest it has seen.\r\n- **The server checks every read.** A malformed batch is dropped whole and counts one strike.\r\n  `rate` is a token bucket per player per packet, with a burst of one second's worth. A client\r\n  packet without a `rate` gets a Studio warning, and `rate = math.huge` means \"no limit, on\r\n  purpose\". The game decides what strikes mean, through `Net.onAbuse`.\r\n- **Packets that arrive before anything listens** are held (up to 64 per packet) and delivered to\r\n  the first listener, as a RemoteEvent would. Reliable batches that arrive before this side's\r\n  `Net.define` are held too. Unreliable ones are dropped, because nobody wants an old stream.\r\n- **Requests** go client to server only. A handler that errors sends the client a plain failure,\r\n  and the full error stays in the server log.\r\n- `Net.stats()` prints sends, bytes and drops per packet per second, so a packet hogging bandwidth\r\n  shows up by name.\r\n\r\nChecking hits and rewinding positions belong in the game's combat code.\r\n\r\n## From Shard to Nova\r\n\r\n| Shard | Nova |\r\n| --- | --- |\r\n| `Shard.new(name)` | `Nova.new(name)` |\r\n| `:withLifecycle({ init, start })` | Same, and `init` must not yield |\r\n| `:withDependencies({ ... })` | Same, and it orders `start` too |\r\n| `:withSignals`, `:getSignal(name)` | Same |\r\n| `:withCleanup(fn)` | Same, but it now runs, and `fn` gets `self`. Shard never ran these |\r\n| `:withConfig`, `:getConfig(key)` | Same. A missing key errors unless a default is given |\r\n| `:withFeatureFlags`, `:isFeatureEnabled(name)` | `Flags.luau` or `:withFlags`, then `self:flag(name)` |\r\n| `debugMode` flag and `self.log:setDebugEnabled` | The `NovaDebug` flag |\r\n| `:withRemoteEvents({ \"Name\" })` then `:getRemoteEvent(\"Name\"):Fire(...)` | `Packets.Name:send(data)`, declared once in Packets.luau |\r\n| `:withRemoteEvents({ { Name = fn } })` | `Packets.Name:listen(fn)` |\r\n| `FireClient`, `FireAll`, `FireAllExcept`, `FireFilter` | `sendTo`, `sendAll`, `sendExcept`, `sendFilter` |\r\n| `:withRemoteFunctions` then `Invoke` | `Net.request`, then `:invoke` and `:handle` |\r\n| `Dispatcher:Subscribe(name, fn)` / `:Publish(name, ...)` | `Events.Name:Connect(fn)` / `:Fire(...)` |\r\n| A Started / Ended event pair | `Events.Name:set(on)` and `:observe(fn)` |\r\n| `Shard.get(name)` | `Nova.get(name)` |\r\n| `Shard.waitFor(name)` | `Nova.await(name, timeout)` |\r\n| `Shard.init()`, `Shard.start()`, BootGuard, `ShardServerStarted` | `Nova.boot({ ... })` |\r\n| `self.log:info(...)` | Same |\r\n\r\nThese are cut, because no game uses them: `withTags`/`getByTag`, `withBindableEvents/Functions`,\r\n`withProfiling`/timers/metrics (use the MicroProfiler), `withHealthCheck`, `Shard.Player`,\r\n`Shard.Server` and the Value package, the global `Shard.createRemote*` functions,\r\n`getAllServices`/`exists`/`getDependencyGraph`/`getFrameworkStats`, and `HandleProgressionEvent`.\r\n\r\n## Developing Nova\r\n\r\nThe Nova Development place runs this repo through `dev.project.json`. Use `rojo serve\r\ndev.project.json`, or run `scripts/build-dev.sh` and import the four models in `build/`.\r\n\r\n- `dev/` is a small game that uses everything (Combat, Inventory, World and Ping), together with\r\n  self-test fixtures.\r\n- **Self-test:** add a Configuration named `NovaFlags` to ReplicatedStorage in edit mode, with a\r\n  boolean attribute `NovaSelfTest = true`, then playtest. The server and client each print a\r\n  `[NovaTest]` line per check (47 server and 20 client checks, covering failure isolation, ordering,\r\n  flags, the bus, every codec, and the wire end to end). They leave `NovaServerTests` and\r\n  `NovaClientTests` on ReplicatedStorage. The fixtures fail on purpose, so remove the override\r\n  afterwards.\r\n","readmeTruncated":false}