├── .gitmodules ├── .luacheckrc ├── LICENSE ├── RDC.rbxl ├── README.md ├── assets └── rodux-visualizer.png ├── rojo.json └── src ├── HotReloadClient.lua ├── HotReloadServer.lua ├── client ├── ClientApi.lua ├── Components │ ├── ApiProvider.lua │ ├── Game.lua │ ├── Inventory.lua │ ├── InventoryMenu.lua │ ├── World.lua │ ├── WorldItem.lua │ └── YAxisBillboard.lua ├── clientReducers │ ├── init.lua │ └── inventory.lua ├── getApiFromComponent.lua ├── init.client.lua ├── main.lua └── thunks │ ├── dropItem.lua │ └── pickUpItem.lua ├── common ├── ApiSpec.lua ├── Dictionary.lua ├── None.lua ├── Typer.lua ├── actions │ ├── addItemsToPlayerInventory.lua │ ├── addItemsToWorld.lua │ ├── addPlayer.lua │ ├── removeItemFromPlayerInventory.lua │ └── removeItemFromWorld.lua ├── commonReducers │ ├── init.lua │ └── world.lua └── objects │ └── Item.lua └── server ├── ServerApi.lua ├── getRandomItemName.lua ├── init.server.lua ├── main.lua ├── networkMiddleware.lua └── serverReducers ├── init.lua └── playerInventories.lua /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "modules/roact"] 2 | path = modules/roact 3 | url = https://github.com/Roblox/roact.git 4 | [submodule "modules/rodux"] 5 | path = modules/rodux 6 | url = https://github.com/Roblox/rodux.git 7 | [submodule "modules/roact-rodux"] 8 | path = modules/roact-rodux 9 | url = https://github.com/Roblox/roact-rodux.git 10 | [submodule "modules/promise"] 11 | path = modules/promise 12 | url = https://github.com/LPGhatguy/roblox-lua-promise.git 13 | -------------------------------------------------------------------------------- /.luacheckrc: -------------------------------------------------------------------------------- 1 | stds.roblox = { 2 | globals = { 3 | "game" 4 | }, 5 | read_globals = { 6 | -- Roblox globals 7 | "script", 8 | 9 | -- Extra functions 10 | "tick", "warn", "spawn", 11 | "wait", "typeof", 12 | 13 | -- Types 14 | "Vector2", "Vector3", 15 | "Color3", 16 | "UDim", "UDim2", 17 | "Rect", 18 | "CFrame", 19 | "Enum", 20 | "Instance", 21 | } 22 | } 23 | 24 | stds.testez = { 25 | read_globals = { 26 | "describe", 27 | "it", "itFOCUS", "itSKIP", 28 | "FOCUS", "SKIP", "HACK_NO_XPCALL", 29 | "expect", 30 | } 31 | } 32 | 33 | ignore = { 34 | "212", -- unused arguments 35 | } 36 | 37 | std = "lua51+roblox" 38 | 39 | files["**/*.spec.lua"] = { 40 | std = "+testez", 41 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. -------------------------------------------------------------------------------- /RDC.rbxl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LPGhatguy/rdc-project/ab5c049fea7ca0dc4d98cedfa1fe1930f1130942/RDC.rbxl -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roact and Rodux Demo Project for RDC 2 | This project is an example of some experimental ways to take advantage of Roact and Rodux in a game besides just UI. 3 | 4 | It is not intended to be an indicator of best practices, but indicates some of the more interesting things you can do with Roact and Rodux. 5 | 6 | ## Overview 7 | This project falls short of being an actual game. It contains a list-based inventory that can be opened with E, and a small number of items that are scattered on the ground. 8 | 9 | Stepping on an item adds it to your inventory, and clicking on an item in your inventory drops it on the ground in front of you. 10 | 11 | The UI, including the inventory and item name pop-ups are rendered using Roact. 12 | 13 | The game world itself is managed mostly by Roact with some manually managed properties as an example of how to keep a non-declarative system in sync with Rodux. 14 | 15 | ## Key Features 16 | 17 | ### Hot-Reloading Shim 18 | Roact and Rodux lend themselves well to hot-reloading code, which enables modifying a game as its running without restarting it or losing any state. 19 | 20 | The React ecosystem has [React Hot Loader](https://github.com/gaearon/react-hot-loader) for doing that directly with React components, but Roact has no parallel right now. 21 | 22 | The Redux pattern, however, is trivial to hot-reload: just keep a list of actions that have been dispatched so far, and on reload, play them back. 23 | 24 | Because this project is using a network replicated Rodux store, this is a bit trickier. I didn't have time to fully explore the idea, but I think there's a lot of interesting potential here. Any robust solution would want to timestamp actions and interleave client/server actions when reloading on playback. 25 | 26 | Hot-reloading can be taken advantage of by running the game in either play solo or server-client mode and using Rojo polling on the server DataModel. As code updates on the filesystem, it will be synced into the game, and the hot-reload shim will trigger a reload. 27 | 28 | Take a look at `src/HotReloadClient.lua` and `src/HotReloadServer.lua` for the shim, and then `src/server/init.server.lua` and `src/client/init.client.lua` for the project-specific implementations. 29 | 30 | There are a couple known issues relating to state not being replicated again after a reload, which could be solved by special-casing pieces of state. I didn't explore this problem space very thoroughly, but I think there could be some powerful techniques to apply. 31 | 32 | ### Replicated Rodux Store 33 | An idea that [David Minnerly (Vocksel)](https://github.com/vocksel) proposed awhile ago was to send Rodux actions over the network to synchronize stores across multiple peers. 34 | 35 | In this project, I tried to build some minor tooling around the idea. 36 | 37 | There are a set of common reducers, located in `src/common/commonReducers` that represent state that must be in sync for each client. In this project, that includes the set of items that are located in the world. 38 | 39 | Both the server and client have their own reducers for private state, located in `src/server/serverReducers` and `src/client/clientReducers` respectively. 40 | 41 | The server chooses which actions to replicate to the client based on a couple simple rules: 42 | 43 | * Any action that mutates a reducer defined in `commonReducers` is always replicated to all clients. 44 | * Any action that has the property `replicateBroadcast` set to `true` is always replicated to all clients. 45 | * Any action that has the property `replicateTo` set to a user's ID will replicate to that user. 46 | 47 | The client communicates back by firing events, which have tighter validation and often can't be represented solely by state transitions. 48 | 49 | ### Strongly-Typed Remote Interface 50 | Unrelated to Roact and Rodux, I wrote a declarative typechecking interface for annotating functions. We don't have static strong typing on Roblox yet, but strict runtime guards and high test coverage are a step in the right direction. 51 | 52 | All communication between the client and server is facilitated by `RemoteEvent` objects created on the server. The objects, their directionality, and their argument validators are specified in `src/common/ApiSpec.lua`. 53 | 54 | The client and server generate objects with regular method calls for each event specified in the spec, which is rigorously checked. Look at `src/client/ClientApi.lua` and `src/server/ServerApi.lua` for those implementations. 55 | 56 | On the server, events can be sent to specific players or broadcasted by either passing a specific user ID as the first argument, or the special value `AllPlayers`. I think that encoding broadcasting vs single player transmission as the same method is easier to reason about. 57 | 58 | Types are checked on both the sending and receiving ends. On the sending end, we want to make sure that we're putting the right values into the remote. Validation is done on the receiving end as well as a first-pass guard against malicious clients. 59 | 60 | The type declaration library is located in `src/common/Typer.lua`. Most of the code is not all that pretty, and it underwent several iterations before landing where it is right now. 61 | 62 | [Osyris](https://github.com/osyrisrblx) built a runtime type checking system that's very similar to Typer called [t](https://github.com/osyrisrblx/t). If you're interested in pursuing this idea further, that's a great place to start. 63 | 64 | ### Library Previews 65 | Some of the techniques and files in the project come from projects that we haven't released yet. 66 | 67 | `src/common/Dictionary.lua` and `src/common/None.lua` come from a project called Cryo that we'll be releasing later this year. Cryo provides utilities for writing functional programs and for working with immutable data structures, and `Dictionary` is one part of it! 68 | 69 | A number of source files reference the "Redux Visualizer" or "Redux DevTools". That project isn't quite ready for release, but when everything is hooked up, it looks like this: 70 | 71 | ![An early version of this project with the Rodux Visualizer panel open](assets/rodux-visualizer.png) 72 | 73 | Using the visualizer, it's possible to inspect into both the client and server Rodux stores and visualize all of their values in real time, as well as step backwards and forward through time! 74 | 75 | ### World Rendered with Roact 76 | An unusual feature of this project is that the main game world is rendered declaratively with Roact. Most projects probably won't go down this route, but it stands as an interesting example of non-Roact interaction with Roact-controlled elements. 77 | 78 | Items dropped in the world are managed by Roact and thus the reconciliation process, but the single-axis billboarded GUIs are oriented towards the camera in a `RenderStepped` loop. 79 | 80 | ## License 81 | This project is available under the CC0 1.0 Universal license. Details are available in [LICENSE](LICENSE). Generally, that means you can whatever you want with it! -------------------------------------------------------------------------------- /assets/rodux-visualizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LPGhatguy/rdc-project/ab5c049fea7ca0dc4d98cedfa1fe1930f1130942/assets/rodux-visualizer.png -------------------------------------------------------------------------------- /rojo.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rdc-project", 3 | "servePort": 8000, 4 | "partitions": { 5 | "common": { 6 | "path": "src/client", 7 | "target": "StarterPlayer.StarterPlayerScripts.RDC" 8 | }, 9 | "server": { 10 | "path": "src/server", 11 | "target": "ServerScriptService.RDC" 12 | }, 13 | "client": { 14 | "path": "src/common", 15 | "target": "ReplicatedStorage.Modules.RDC" 16 | }, 17 | "hot-reload-server": { 18 | "path": "src/HotReloadServer.lua", 19 | "target": "ReplicatedStorage.HotReloadServer" 20 | }, 21 | "hot-reload-client": { 22 | "path": "src/HotReloadClient.lua", 23 | "target": "ReplicatedStorage.HotReloadClient" 24 | }, 25 | "modules/roact": { 26 | "path": "modules/roact/lib", 27 | "target": "ReplicatedStorage.Modules.Roact" 28 | }, 29 | "modules/rodux": { 30 | "path": "modules/rodux/lib", 31 | "target": "ReplicatedStorage.Modules.Rodux" 32 | }, 33 | "modules/roact-rodux": { 34 | "path": "modules/roact-rodux/lib", 35 | "target": "ReplicatedStorage.Modules.RoactRodux" 36 | }, 37 | "modules/promise": { 38 | "path": "modules/promise/lib", 39 | "target": "ReplicatedStorage.Modules.Promise" 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /src/HotReloadClient.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local savedState = nil 4 | 5 | local HotReloadClient = {} 6 | 7 | function HotReloadClient.start(options) 8 | local getCurrent = options.getCurrent 9 | local getNext = options.getNext 10 | local beforeUnload = options.beforeUnload 11 | 12 | -- The server gets to decide when a hot-reload is triggered. 13 | local reloadBindable = ReplicatedStorage:WaitForChild("HotReloaded") 14 | 15 | reloadBindable.OnClientInvoke = function() 16 | reloadBindable.OnClientInvoke = nil 17 | 18 | savedState = beforeUnload() 19 | 20 | -- Do the job of StarterPlayerScripts over again to restart this script. 21 | -- This feels pretty hacky. 22 | local current = getCurrent() 23 | local parent = current.Parent 24 | local next = getNext() 25 | current:Destroy() 26 | next.Parent = parent 27 | end 28 | end 29 | 30 | function HotReloadClient.getSavedState() 31 | return savedState 32 | end 33 | 34 | return HotReloadClient -------------------------------------------------------------------------------- /src/HotReloadServer.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local Players = game:GetService("Players") 3 | 4 | local reloadScheduled = false 5 | 6 | local reloadBindable = Instance.new("RemoteFunction") 7 | reloadBindable.Name = "HotReloaded" 8 | reloadBindable.Parent = ReplicatedStorage 9 | 10 | local function listenToChangesRecursive(root, connections, callback) 11 | table.insert(connections, (root.Changed:Connect(callback))) 12 | 13 | for _, child in ipairs(root:GetChildren()) do 14 | listenToChangesRecursive(child, connections, callback) 15 | end 16 | end 17 | 18 | local function replace(object) 19 | -- Do you ever get that feeling that everything in your house has been 20 | -- replaced by an exact replica? 21 | 22 | local new = object:Clone() 23 | new.Parent = object.Parent 24 | object:Destroy() 25 | end 26 | 27 | local savedState = nil 28 | 29 | local HotReloadServer = {} 30 | 31 | function HotReloadServer.start(options) 32 | local objectsToWatch = options.watch 33 | local beforeUnload = options.beforeUnload 34 | local afterReload = options.afterReload 35 | 36 | local connections = {} 37 | 38 | local function changeCallback() 39 | if reloadScheduled then 40 | return 41 | end 42 | 43 | print("Scheduled hot reload!") 44 | 45 | reloadScheduled = true 46 | 47 | for _, connection in ipairs(connections) do 48 | connection:Disconnect() 49 | end 50 | 51 | wait(0.1) 52 | 53 | savedState = beforeUnload() 54 | 55 | for _, object in ipairs(objectsToWatch) do 56 | replace(object) 57 | end 58 | 59 | wait(0.1) 60 | 61 | for _, player in ipairs(Players:GetPlayers()) do 62 | reloadBindable:InvokeClient(player) 63 | end 64 | 65 | afterReload() 66 | 67 | reloadScheduled = false 68 | end 69 | 70 | spawn(function() 71 | for _, object in ipairs(objectsToWatch) do 72 | table.insert(connections, (object.DescendantAdded:Connect(changeCallback))) 73 | 74 | listenToChangesRecursive(object, connections, changeCallback) 75 | end 76 | end) 77 | end 78 | 79 | function HotReloadServer.getSavedState() 80 | return savedState 81 | end 82 | 83 | return HotReloadServer -------------------------------------------------------------------------------- /src/client/ClientApi.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This object's job is to read the common ApiSpec, which defines the protocol 3 | for communicating with the server and the types that each method accepts. 4 | 5 | On connecting to the server via `connect`, we generate an object that has 6 | a method for each RemoteEvent that attached validation on both ends. 7 | 8 | I've found that this is a super nice way to think about network 9 | communication in Roblox, since it lines up with other strongly-typed RPC 10 | systems. 11 | ]] 12 | 13 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 14 | 15 | local ApiSpec = require(ReplicatedStorage.Modules.RDC.ApiSpec) 16 | 17 | local ClientApi = {} 18 | ClientApi.prototype = {} 19 | ClientApi.__index = ClientApi.prototype 20 | 21 | function ClientApi.connect(handlers) 22 | assert(typeof(handlers) == "table") 23 | 24 | local self = {} 25 | 26 | setmetatable(self, ClientApi) 27 | 28 | local remotes = ReplicatedStorage:WaitForChild("Events") 29 | 30 | for name, endpoint in pairs(ApiSpec.fromClient) do 31 | local remote = remotes:WaitForChild("fromClient-" .. name) 32 | 33 | self[name] = function(_, ...) 34 | endpoint.arguments(...) 35 | 36 | remote:FireServer(...) 37 | end 38 | end 39 | 40 | for name, endpoint in pairs(ApiSpec.fromServer) do 41 | local remote = remotes:WaitForChild("fromServer-" .. name) 42 | 43 | local handler = handlers[name] 44 | 45 | if handler == nil then 46 | error(("Need to implement client handler for %q"):format(name), 2) 47 | end 48 | 49 | remote.OnClientEvent:Connect(function(...) 50 | endpoint.arguments(...) 51 | 52 | handler(...) 53 | end) 54 | end 55 | 56 | for name in pairs(handlers) do 57 | if ApiSpec.fromServer[name] == nil then 58 | error(("Invalid handler %q specified!"):format(name), 2) 59 | end 60 | end 61 | 62 | return self 63 | end 64 | 65 | return ClientApi -------------------------------------------------------------------------------- /src/client/Components/ApiProvider.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This is a simple example of a feature called context. 3 | 4 | Context is unstable, and we're working on building a new, more powerful API 5 | to replace it: https://github.com/Roblox/roact/issues/4 6 | 7 | Context is usually used for dependency injection, which is what we're doing 8 | here. This project uses an instance of an object called ClientApi that 9 | represents our connection to the server and all the actions we can perform. 10 | 11 | We could've crammed that object into a global or module as a singleton 12 | instead of going through the hassle of dependency injection, but we gain 13 | some nice things by doing this: 14 | 15 | 1. Testing becomes a lot easier, since we can inject exactly the set of fake 16 | objects we need for a given test, and don't have to worry about mutating 17 | globals to get mocked behavior. 18 | 19 | 2. We can technically replace the API implementation mid-run, which can 20 | assist in hot-reloading. This isn't taken advantage of right now. 21 | 22 | 3. We could have different or multiple API objects in the tree, since 23 | they're scoped using Roact components. 24 | 25 | To get the API out of context and consume it, there's a handy method named 26 | getApiFromComponent, which accepts a component instance. 27 | ]] 28 | 29 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 30 | 31 | local Roact = require(ReplicatedStorage.Modules.Roact) 32 | 33 | local ApiProvider = Roact.Component:extend("ApiProvider") 34 | 35 | function ApiProvider:init() 36 | assert(self.props.api ~= nil) 37 | 38 | self._context.ClientApi = self.props.api 39 | end 40 | 41 | function ApiProvider:render() 42 | return Roact.oneChild(self.props[Roact.Children]) 43 | end 44 | 45 | return ApiProvider -------------------------------------------------------------------------------- /src/client/Components/Game.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This Roact component represents our entire game. 3 | 4 | In most cases, you'll only use Roact for constructing your UI, but in this 5 | project, I elected to try to manage the game world partially with Roact as 6 | well for fun. 7 | 8 | The traditional top-level component name in React is "App," we generally use 9 | that for Roact too, but I have a hunch that there's a better name. 10 | ]] 11 | 12 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 13 | local Workspace = game:GetService("Workspace") 14 | 15 | local Roact = require(ReplicatedStorage.Modules.Roact) 16 | 17 | local e = Roact.createElement 18 | 19 | local InventoryMenu = require(script.Parent.InventoryMenu) 20 | local World = require(script.Parent.World) 21 | 22 | local function Game(props) 23 | return e("ScreenGui", { 24 | ZIndexBehavior = Enum.ZIndexBehavior.Sibling, 25 | ResetOnSpawn = false, 26 | }, { 27 | InventoryContainer = e("Frame", { 28 | Size = UDim2.new(0, 400, 0, 400), 29 | Position = UDim2.new(0.5, 0, 0.5, 0), 30 | AnchorPoint = Vector2.new(0.5, 0.5), 31 | BackgroundTransparency = 1, 32 | }, { 33 | Inventory = e(InventoryMenu), 34 | }), 35 | 36 | -- Even through our UI is being rendered inside a PlayerGui, we can 37 | -- always take advantage of a feature called portals to put instances 38 | -- elsewhere. 39 | 40 | -- Portals are a feature that makes having a virtual tree worthwhile, 41 | -- since implementing them without having formalized destructors is 42 | -- bug-prone! 43 | World = e(Roact.Portal, { 44 | target = Workspace, 45 | }, { 46 | World = e(World), 47 | }) 48 | }) 49 | end 50 | 51 | return Game -------------------------------------------------------------------------------- /src/client/Components/Inventory.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This component renders a list of inventory items and exposes a callback when 3 | an individual item in the list is clicked on. 4 | 5 | It has a very simple bottom-up sizing approach based on the number of items 6 | in the list. The required sizing logic for flexible grids is quite a bit 7 | more complicated, or else I would've created a drag-and-drop grid instead. 8 | 9 | Often times, we don't want to have to know the size of the items we're 10 | rendering in grids and lists, but it's difficult to implement that form of 11 | dynamic bottom-up sizing in Roblox right now. I opted to avoid it entirely. 12 | ]] 13 | 14 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 15 | 16 | local Roact = require(ReplicatedStorage.Modules.Roact) 17 | 18 | local e = Roact.createElement 19 | 20 | local ITEM_PADDING = 4 21 | local ITEM_HEIGHT = 40 22 | 23 | local function Inventory(props) 24 | local items = props.items 25 | local onItemClicked = props.onItemClicked 26 | 27 | -- The order that items appear must be deterministic, so we create a list! 28 | local itemList = {} 29 | 30 | for _, item in pairs(items) do 31 | table.insert(itemList, item) 32 | end 33 | 34 | table.sort(itemList, function(a, b) 35 | return a.name < b.name 36 | end) 37 | 38 | -- It's easy to dynamically build up children in Roact since the description 39 | -- of our UI is just a function returning objects. 40 | local children = {} 41 | 42 | children.Layout = e("UIListLayout", { 43 | SortOrder = Enum.SortOrder.LayoutOrder, 44 | }) 45 | 46 | for index, item in ipairs(itemList) do 47 | local slot = e("Frame", { 48 | Size = UDim2.new(1, 0, 0, ITEM_HEIGHT), 49 | BackgroundColor3 = Color3.new(1, 1, 1), 50 | LayoutOrder = index, 51 | }, { 52 | Inner = e("TextButton", { 53 | BackgroundTransparency = 1, 54 | Size = UDim2.new(1, -ITEM_PADDING * 2, 1, -ITEM_PADDING * 2), 55 | Position = UDim2.new(0, ITEM_PADDING, 0, ITEM_PADDING), 56 | Font = Enum.Font.SourceSans, 57 | TextSize = 24, 58 | Text = item.name, 59 | TextWrap = true, 60 | 61 | [Roact.Event.Activated] = function() 62 | onItemClicked(item.id) 63 | end, 64 | }), 65 | }) 66 | 67 | children[item.id] = slot 68 | end 69 | 70 | return e("ScrollingFrame", { 71 | Size = UDim2.new(1, 0, 1, 0), 72 | BackgroundColor3 = Color3.new(0, 0, 0), 73 | BorderSizePixel = 0, 74 | BackgroundTransparency = 0.5, 75 | CanvasSize = UDim2.new(1, 0, 0, ITEM_HEIGHT * #itemList), 76 | }, children) 77 | end 78 | 79 | return Inventory -------------------------------------------------------------------------------- /src/client/Components/InventoryMenu.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This component wraps an Inventory component and connects to the store and 3 | API instance to provide real functionality. 4 | 5 | It also connects to UserInputService to allow the drawer to be opened with 6 | the 'E' key. 7 | 8 | Note that this component has almost no UI logic of its own outside plumbing 9 | methods down to Inventory. This is a common pattern in React and Roact and 10 | makes it really easy to re-use display logic in multiple places. 11 | 12 | A good rule of thumb is to design components without thinking about Rodux or 13 | where data comes from. Once that's complete, write smart wrapper components 14 | that own state, connect to the store, and perform side effects. This will 15 | usually give you really nice, easy to reason about code. 16 | ]] 17 | 18 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 19 | local UserInputService = game:GetService("UserInputService") 20 | 21 | local Roact = require(ReplicatedStorage.Modules.Roact) 22 | local RoactRodux = require(ReplicatedStorage.Modules.RoactRodux) 23 | 24 | local Inventory = require(script.Parent.Inventory) 25 | local dropItem = require(script.Parent.Parent.thunks.dropItem) 26 | local getApiFromComponent = require(script.Parent.Parent.getApiFromComponent) 27 | 28 | local e = Roact.createElement 29 | 30 | local InventoryMenu = Roact.Component:extend("InventoryMenu") 31 | 32 | function InventoryMenu:init() 33 | self.state = { 34 | open = false, 35 | } 36 | 37 | self.api = getApiFromComponent(self) 38 | end 39 | 40 | function InventoryMenu:render() 41 | if not self.state.open then 42 | return nil 43 | end 44 | 45 | return e(Inventory, { 46 | items = self.props.items, 47 | onItemClicked = function(itemId) 48 | self.props.dropItem(self.api, itemId) 49 | end, 50 | }) 51 | end 52 | 53 | function InventoryMenu:didMount() 54 | -- We have a couple utilities internally that we use to manage connections 55 | -- to services declaratively. 56 | -- I opted not to use those in this example to be more explicit. 57 | self._connection = UserInputService.InputEnded:Connect(function(inputObject) 58 | if inputObject.UserInputType ~= Enum.UserInputType.Keyboard then 59 | return 60 | end 61 | 62 | if inputObject.keyCode ~= Enum.KeyCode.E then 63 | return 64 | end 65 | 66 | -- We use the function variant of setState here despite it being more 67 | -- complicated because we depend on the previous value of state. 68 | -- Roact doesn't guarantee that state updates happen immediately, so if 69 | -- the user is mashing their E key to toggle the inventory, we want to 70 | -- make sure we don't lose any of those events. 71 | self:setState(function(state) 72 | return { 73 | open = not state.open, 74 | } 75 | end) 76 | end) 77 | end 78 | 79 | function InventoryMenu:willUnmount() 80 | self._connection:Disconnect() 81 | end 82 | 83 | InventoryMenu = RoactRodux.connect( 84 | function(state) 85 | -- Our component will update iff the player's inventory updates. 86 | return { 87 | items = state.inventory, 88 | } 89 | end, 90 | function(dispatch) 91 | return { 92 | dropItem = function(...) 93 | return dispatch(dropItem(...)) 94 | end, 95 | } 96 | end 97 | )(InventoryMenu) 98 | 99 | return InventoryMenu -------------------------------------------------------------------------------- /src/client/Components/World.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Creates all of the items in the world and allows the user to interact with 3 | them by picking them up. 4 | 5 | In a real game, I think managing your game world with Roact might be kind of 6 | strange. Roact lends itself very well to deep tree structures, and many game 7 | world representations are flat. 8 | 9 | In this project, the world is rendered via a hybrid Roact/non-Roact 10 | strategy. While all of the items are rendered and managed via Roact's 11 | reconciliation step, things that change every frame are managed manually. 12 | 13 | Roact makes it fairly easy to drop in and out of Roact at any point in your 14 | hierarchy, so if something is too clunky to be declarative or is performance 15 | sensitive, that's always a way through. 16 | ]] 17 | 18 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 19 | 20 | local Modules = ReplicatedStorage.Modules 21 | 22 | local Roact = require(Modules.Roact) 23 | local RoactRodux = require(Modules.RoactRodux) 24 | 25 | local WorldItem = require(script.Parent.WorldItem) 26 | 27 | local getApiFromComponent = require(script.Parent.Parent.getApiFromComponent) 28 | local pickUpItem = require(script.Parent.Parent.thunks.pickUpItem) 29 | 30 | local e = Roact.createElement 31 | 32 | local World = Roact.Component:extend("World") 33 | 34 | function World:init() 35 | self.api = getApiFromComponent(self) 36 | end 37 | 38 | function World:render() 39 | local children = {} 40 | 41 | for id, item in pairs(self.props.world) do 42 | children[id] = e(WorldItem, { 43 | item = item, 44 | onTouched = function() 45 | self.props.pickUpItem(self.api, id) 46 | end, 47 | }) 48 | end 49 | 50 | return e("Folder", nil, children) 51 | end 52 | 53 | World = RoactRodux.connect( 54 | function(state) 55 | return { 56 | world = state.world, 57 | } 58 | end, 59 | function(dispatch) 60 | return { 61 | pickUpItem = function(...) 62 | return dispatch(pickUpItem(...)) 63 | end, 64 | } 65 | end 66 | )(World) 67 | 68 | return World -------------------------------------------------------------------------------- /src/client/Components/WorldItem.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Represents in an item as it's visible in the game world. 3 | ]] 4 | 5 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 6 | local Players = game:GetService("Players") 7 | 8 | local Modules = ReplicatedStorage.Modules 9 | 10 | local Roact = require(Modules.Roact) 11 | local e = Roact.createElement 12 | 13 | local YAxisBillboard = require(script.Parent.YAxisBillboard) 14 | 15 | local function WorldItem(props) 16 | local item = props.item 17 | local onTouched = props.onTouched 18 | 19 | return e("Part", { 20 | CFrame = CFrame.new(item.position), 21 | Size = Vector3.new(2, 0.5, 2), 22 | Color = item.color, 23 | Anchored = true, 24 | 25 | -- Roact can handle setting up events for us, and will automatically 26 | -- disconnect and reconnect them as needed. 27 | [Roact.Event.Touched] = function(_, otherPart) 28 | -- I think this is the best way to make sure this is a character 29 | -- touching the item? 30 | 31 | local character = Players.LocalPlayer.Character 32 | 33 | if character == nil then 34 | return 35 | end 36 | 37 | if not otherPart:IsDescendantOf(character) then 38 | return 39 | end 40 | 41 | onTouched() 42 | end, 43 | }, { 44 | UI = e(YAxisBillboard, { 45 | position = item.position + Vector3.new(0, 2, 0), 46 | size = Vector2.new(3, 3), 47 | }, { 48 | Name = e("TextLabel", { 49 | Size = UDim2.new(1, 0, 1, 0), 50 | Font = Enum.Font.SourceSans, 51 | TextSize = 60, 52 | Text = item.name, 53 | TextWrap = true, 54 | BackgroundColor3 = Color3.fromRGB(4, 134, 204), 55 | BackgroundTransparency = 0.3, 56 | BorderSizePixel = 0, 57 | }), 58 | }), 59 | }) 60 | end 61 | 62 | return WorldItem -------------------------------------------------------------------------------- /src/client/Components/YAxisBillboard.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | A SurfaceGui-based billboard that only pivots on the Y axis. 3 | 4 | The primary UI container is rendered with Roact, but the pivoting is handled 5 | by manually changing the containing part's CFrame directly every frame. 6 | 7 | This file is a good example of mutating properties manually on an otherwise 8 | Roact-owned object. It's a very powerful way to get a performance boost at 9 | the cost of some readability and potentially some bugs. 10 | ]] 11 | 12 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 13 | local RunService = game:GetService("RunService") 14 | local Workspace = game:GetService("Workspace") 15 | 16 | local Modules = ReplicatedStorage.Modules 17 | 18 | local Roact = require(Modules.Roact) 19 | local e = Roact.createElement 20 | 21 | local YAxisBillboard = Roact.Component:extend("YAxisBillboard") 22 | 23 | function YAxisBillboard:init() 24 | self.partRef = Roact.createRef() 25 | end 26 | 27 | function YAxisBillboard:_updatePosition() 28 | -- This function gets invoked on initial mount, every time the component is 29 | -- updated via Roact, and every frame. That helps make sure that we won't 30 | -- be out-of-sync with our props. 31 | 32 | local cameraCFrame = Workspace.CurrentCamera.CFrame 33 | 34 | local angle = math.atan2( 35 | self.props.position.x - cameraCFrame.p.x, 36 | self.props.position.z - cameraCFrame.p.z) 37 | 38 | local uiLocation = CFrame.Angles(0, angle, 0) + self.props.position 39 | 40 | self.partRef.current.CFrame = uiLocation 41 | end 42 | 43 | function YAxisBillboard:render() 44 | local size = self.props.size 45 | 46 | return e("Part", { 47 | -- When doing manual mutation alongside Roact, it's important to avoid 48 | -- setting the properties we're managing manually from inside render. 49 | -- Otherwise, the reconciler will fight you! 50 | 51 | Size = Vector3.new(size.X, size.Y, 0.1), 52 | Transparency = 1, 53 | Anchored = true, 54 | CanCollide = false, 55 | 56 | [Roact.Ref] = self.partRef, 57 | }, { 58 | -- If we wanted interaction with our ScreenGui, this is where we could 59 | -- use Roact.Portal to jump into the local player's PlayerGui 60 | 61 | UI = e("SurfaceGui", { 62 | Face = Enum.NormalId.Front, 63 | CanvasSize = 100 * size, 64 | }, self.props[Roact.Children]), 65 | }) 66 | end 67 | 68 | function YAxisBillboard:didMount() 69 | self._connection = RunService.RenderStepped:Connect(function() 70 | self:_updatePosition() 71 | end) 72 | 73 | self:_updatePosition() 74 | end 75 | 76 | function YAxisBillboard:didUpdate() 77 | self:_updatePosition() 78 | end 79 | 80 | function YAxisBillboard:willUnmount() 81 | self._connection:Disconnect() 82 | end 83 | 84 | return YAxisBillboard -------------------------------------------------------------------------------- /src/client/clientReducers/init.lua: -------------------------------------------------------------------------------- 1 | local subreducers = {} 2 | 3 | -- Bundle all of our submodules automatically. 4 | for _, child in ipairs(script:GetChildren()) do 5 | if child:IsA("ModuleScript") then 6 | subreducers[child.Name] = require(child) 7 | end 8 | end 9 | 10 | return subreducers -------------------------------------------------------------------------------- /src/client/clientReducers/inventory.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | A client's view of their inventory uses a different reducer than the 3 | server's view of all client's inventories. 4 | 5 | This has the advantage of making client-side code cleaner, but I did run 6 | into a bug I'm not sure how to solve sort of late into this design. 7 | 8 | When the server reloads (or if a player had saved items from a previous play 9 | session) the server only sends an initial state to the client. That worked 10 | in the simple case (all information is shared) but sort of falls apart 11 | because of this difference. 12 | 13 | The server will send over the common information, which doesn't include any 14 | information specific to the client, but also doesn't include any actions 15 | that only affected server or client specific information. 16 | ]] 17 | 18 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 19 | 20 | local Dictionary = require(ReplicatedStorage.Modules.RDC.Dictionary) 21 | local None = require(ReplicatedStorage.Modules.RDC.None) 22 | 23 | local function inventory(state, action) 24 | state = state or {} 25 | 26 | if action.type == "addItemsToPlayerInventory" then 27 | return Dictionary.join(state, action.items) 28 | elseif action.type == "removeItemFromPlayerInventory" then 29 | return Dictionary.join(state, { 30 | [action.itemId] = None, 31 | }) 32 | end 33 | 34 | return state 35 | end 36 | 37 | return inventory -------------------------------------------------------------------------------- /src/client/getApiFromComponent.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This is a little utility to pull a ClientApi instance out of Roact's context 3 | and hand it to a component. 4 | 5 | Check out Components/ApiProvider for an introduction into context and why we 6 | use this strategy. 7 | ]] 8 | 9 | local function getApiFromComponent(componentInstance) 10 | local api = componentInstance._context.ClientApi 11 | 12 | if api == nil then 13 | error("Failed to find ClientApi in component's context!", 2) 14 | end 15 | 16 | return api 17 | end 18 | 19 | return getApiFromComponent -------------------------------------------------------------------------------- /src/client/init.client.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This is the entry-point for the client. 3 | 4 | It mostly just contains setup code for hot-reloading, which is used in 5 | development only. 6 | ]] 7 | 8 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 9 | local StarterPlayer = game:GetService("StarterPlayer") 10 | 11 | local HotReloadClient = require(ReplicatedStorage.HotReloadClient) 12 | 13 | local savedState = HotReloadClient.getSavedState() 14 | local savedActions = {} 15 | 16 | if savedState ~= nil then 17 | savedActions = savedState.savedActions 18 | end 19 | 20 | local context = { 21 | destructors = {}, 22 | savedActions = savedActions, 23 | running = true, 24 | wasReloaded = savedState ~= nil, 25 | } 26 | 27 | HotReloadClient.start({ 28 | getNext = function() 29 | return StarterPlayer.StarterPlayerScripts.RDC:Clone() 30 | end, 31 | 32 | getCurrent = function() 33 | return script 34 | end, 35 | 36 | beforeUnload = function() 37 | context.running = false 38 | 39 | for _, destructor in ipairs(context.destructors) do 40 | local ok, result = pcall(destructor) 41 | 42 | if not ok then 43 | warn("Failure during destruction: " .. result) 44 | end 45 | end 46 | 47 | return { 48 | savedActions = context.savedActions, 49 | } 50 | end, 51 | }) 52 | 53 | local main = require(script.main) 54 | main(context) -------------------------------------------------------------------------------- /src/client/main.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local Players = game:GetService("Players") 3 | 4 | local Modules = ReplicatedStorage:WaitForChild("Modules") 5 | 6 | local Roact = require(Modules.Roact) 7 | local Rodux = require(Modules.Rodux) 8 | local RoactRodux = require(Modules.RoactRodux) 9 | 10 | -- The Rodux DevTools aren't available yet! Check the README for more details. 11 | -- local RoduxVisualizer = require(Modules.RoduxVisualizer) 12 | 13 | local Dictionary = require(Modules.RDC.Dictionary) 14 | local commonReducers = require(Modules.RDC.commonReducers) 15 | 16 | local clientReducers = require(script.Parent.clientReducers) 17 | local ClientApi = require(script.Parent.ClientApi) 18 | 19 | local Game = require(script.Parent.Components.Game) 20 | local ApiProvider = require(script.Parent.Components.ApiProvider) 21 | 22 | return function(context) 23 | local LocalPlayer = Players.LocalPlayer 24 | 25 | local reducer = Rodux.combineReducers(Dictionary.join(commonReducers, clientReducers)) 26 | 27 | local api 28 | local store 29 | 30 | -- We define our main function explicitly instead of running it right away 31 | -- because we want to make sure we're connected and synced with the server 32 | -- first. 33 | local function main() 34 | local ui = Roact.mount(Roact.createElement(RoactRodux.StoreProvider, { 35 | store = store, 36 | }, { 37 | Roact.createElement(ApiProvider, { 38 | api = api, 39 | }, { 40 | Game = Roact.createElement(Game), 41 | }), 42 | }), LocalPlayer.PlayerGui, "RDC Project") 43 | 44 | table.insert(context.destructors, function() 45 | Roact.unmount(ui) 46 | end) 47 | 48 | print("Client started!") 49 | end 50 | 51 | -- This is a custom Rodux middleware that automatically saves any local 52 | -- actions that are dispatched in order to replay them when we hot-reload. 53 | local function saveActionsMiddleware(nextDispatch) 54 | return function(action) 55 | if not action.replicated then 56 | table.insert(context.savedActions, action) 57 | end 58 | 59 | return nextDispatch(action) 60 | end 61 | end 62 | 63 | -- Once the Rodux DevTools are available publicly, this will be revisited. 64 | -- When I was working on this project, I used this config: 65 | 66 | -- local devTools = RoduxVisualizer.createDevTools({ 67 | -- mode = RoduxVisualizer.Mode.Integrated, 68 | -- toggleHotkey = Enum.KeyCode.Y, 69 | -- visibleOnStartup = false, 70 | -- attachTo = LocalPlayer:WaitForChild("PlayerGui"), 71 | -- }) 72 | 73 | api = ClientApi.connect({ 74 | initialStoreState = function(initialState) 75 | -- Apply any saved actions from the last reload. 76 | -- The actions in this list are only those that were triggered on 77 | -- the client, since the shared state should already be populated 78 | -- correctly from the server. 79 | for _, action in ipairs(context.savedActions) do 80 | initialState = reducer(initialState, action) 81 | end 82 | 83 | store = Rodux.Store.new(reducer, initialState, { 84 | -- Thunks are functions that we dispatch to the store. It's a 85 | -- handy way to get a reference to the store and have 86 | -- side-effects while still looking like regular actions! 87 | Rodux.thunkMiddleware, 88 | 89 | -- This is our custom hot-reloading middleware defined above. 90 | saveActionsMiddleware, 91 | 92 | -- The Redux DevTools middleware, retrieved above. 93 | -- devTools.middleware, 94 | }) 95 | 96 | table.insert(context.destructors, function() 97 | store:destruct() 98 | end) 99 | 100 | main() 101 | end, 102 | 103 | storeAction = function(action) 104 | if store ~= nil then 105 | store:dispatch(action) 106 | end 107 | end, 108 | }) 109 | 110 | print("Client ready!") 111 | 112 | api:clientStart() 113 | end -------------------------------------------------------------------------------- /src/client/thunks/dropItem.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This is a thunk for dropping items; a small intro to thunks is available in 3 | pickUpItem. 4 | ]] 5 | 6 | local function dropItem(api, itemId) 7 | return function(store) 8 | local state = store:getState() 9 | 10 | local item = state.inventory[itemId] 11 | 12 | if item == nil then 13 | warn("Can't drop item with ID " .. itemId) 14 | return 15 | end 16 | 17 | api:dropItem(itemId) 18 | end 19 | end 20 | 21 | return dropItem -------------------------------------------------------------------------------- /src/client/thunks/pickUpItem.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This is a thunk for picking up items! 3 | 4 | Thunks are dispatched just like regular actions, but handled by a special 5 | middleware included with Rodux but not enabled by default. They can be used 6 | to get a reference to the store and are useful for causing side effects, 7 | like sending events over the network! 8 | ]] 9 | 10 | local function pickUpItem(api, itemId) 11 | return function(store) 12 | local state = store:getState() 13 | 14 | local item = state.world[itemId] 15 | 16 | if item == nil then 17 | warn("Can't pick up item with ID " .. itemId) 18 | return 19 | end 20 | 21 | api:pickUpItem(itemId) 22 | end 23 | end 24 | 25 | return pickUpItem -------------------------------------------------------------------------------- /src/common/ApiSpec.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This file specifies the protocol for communication between the client and 3 | server. 4 | 5 | It uses 'Typer' as a way to encode type signatures into the remotes, which 6 | are checked on both ends. This should be enough to verify that well-behaved 7 | clients are obeying the API contract, and acts as a first-pass guard against 8 | malicious clients. 9 | 10 | Both the client and server must implement the correct points of this API. 11 | Each remote is only one-way to prevent low-hanging fruit exploits related to 12 | causing a server to yield forever when using RemoteFunction objects. 13 | 14 | The server will automatically generate a RemoteEvent object for every object 15 | in this table. 16 | 17 | The client will automatically wait for every event to exist and connect to 18 | each of them. 19 | ]] 20 | 21 | local Typer = require(script.Parent.Typer) 22 | 23 | return { 24 | fromClient = { 25 | clientStart = { 26 | arguments = Typer.args(), 27 | }, 28 | pickUpItem = { 29 | arguments = Typer.args( 30 | {"itemId", Typer.type("string")} 31 | ), 32 | }, 33 | dropItem = { 34 | arguments = Typer.args( 35 | {"itemId", Typer.type("string")} 36 | ), 37 | }, 38 | }, 39 | fromServer = { 40 | initialStoreState = { 41 | arguments = Typer.args( 42 | {"state", Typer.any()} 43 | ) 44 | }, 45 | storeAction = { 46 | arguments = Typer.args( 47 | {"action", Typer.type("table")} 48 | ) 49 | }, 50 | }, 51 | } -------------------------------------------------------------------------------- /src/common/Dictionary.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Defines utilities for working with 'dictionary-like' tables. 3 | 4 | This file, along with None.lua, come from a library we'll be releasing soon 5 | called Cryo. It has a set of utilities, like this one, to help make working 6 | with immutable data structures easier. 7 | 8 | Dictionaries can be indexed by any value, but don't have the ordering 9 | expectations that lists have. 10 | ]] 11 | 12 | local None = require(script.Parent.None) 13 | 14 | local Dictionary = {} 15 | 16 | --[[ 17 | Combine a number of dictionary-like tables into a new table. 18 | 19 | Keys specified in later tables will overwrite keys in previous tables. 20 | 21 | Use `None` as a value to remove a key. This is necessary because 22 | Lua does not distinguish between a value not being present in a table and a 23 | value being `nil`. 24 | ]] 25 | function Dictionary.join(...) 26 | local new = {} 27 | 28 | for i = 1, select("#", ...) do 29 | local source = select(i, ...) 30 | 31 | for key, value in pairs(source) do 32 | if value == None then 33 | new[key] = nil 34 | else 35 | new[key] = value 36 | end 37 | end 38 | end 39 | 40 | return new 41 | end 42 | 43 | return Dictionary -------------------------------------------------------------------------------- /src/common/None.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Represents a value that is intentionally present, but should be interpreted 3 | as `nil`. 4 | 5 | None is used by included utilities to make removing values more ergonomic. 6 | ]] 7 | 8 | local None = newproxy(true) 9 | 10 | getmetatable(None).__tostring = function() 11 | return "None" 12 | end 13 | 14 | return None -------------------------------------------------------------------------------- /src/common/Typer.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This is a prototype module that helps encode strong runtime type checks for 3 | functions. 4 | 5 | It's used in a couple different functions, as well as in ApiSpec to define 6 | the correct arguments for use in RemoteEvent objects. 7 | 8 | Typer is a prototype and won't make it into an official library in its 9 | current form. 10 | 11 | Check out "t" by Osyris for a more fleshed out approach: 12 | 13 | https://github.com/osyrisrblx/t 14 | ]] 15 | 16 | local Typer = {} 17 | 18 | local IS_SCHEMA = newproxy(true) 19 | 20 | local function makeSchema(name, fn) 21 | local check = { 22 | [IS_SCHEMA] = true, 23 | name = name, 24 | validate = fn, 25 | } 26 | 27 | setmetatable(check, { 28 | __call = function(_, ...) 29 | return fn(...) 30 | end 31 | }) 32 | 33 | return check 34 | end 35 | 36 | function Typer.args(...) 37 | local argsSchema = {...} 38 | 39 | return function(...) 40 | if select("#", ...) > #argsSchema then 41 | local message = ("Too many arguments passed in. Expected %d arguments or fewer, got %d"):format( 42 | #argsSchema, 43 | select("#", ...) 44 | ) 45 | error(message, 3) 46 | end 47 | 48 | for index, arg in ipairs(argsSchema) do 49 | local argName = arg[1] 50 | local argSchema = arg[2] 51 | local value = select(index, ...) 52 | 53 | local success, err = argSchema.validate(value) 54 | 55 | if not success then 56 | local message = ("Bad argument %s (#%d), expected %s, but %s"):format( 57 | argName, 58 | index, 59 | argSchema.name, 60 | err 61 | ) 62 | error(message, 3) 63 | end 64 | 65 | end 66 | end 67 | end 68 | 69 | function Typer.instance(expectedInstanceClass) 70 | assert(typeof(expectedInstanceClass) == "string", "expectedInstanceClass must be a string") 71 | 72 | local name = ("Instance(%s)"):format(expectedInstanceClass) 73 | 74 | return makeSchema(name, function(value) 75 | local actualType = typeof(value) 76 | 77 | if actualType ~= "Instance" then 78 | local message = ("got value of type %s"):format(actualType) 79 | 80 | return false, message 81 | end 82 | 83 | if value:IsA(expectedInstanceClass) then 84 | return true 85 | else 86 | local message = ("got instance of class %s"):format(value.ClassName) 87 | 88 | return false, message 89 | end 90 | end) 91 | end 92 | 93 | function Typer.type(expectedType) 94 | assert(typeof(expectedType) == "string", "expectedType must be a string") 95 | 96 | return makeSchema(expectedType, function(value) 97 | local actualType = typeof(value) 98 | if actualType == expectedType then 99 | return true 100 | else 101 | local message = ("got value of type %s"):format(actualType) 102 | 103 | return false, message 104 | end 105 | end) 106 | end 107 | 108 | function Typer.any() 109 | return makeSchema("any", function(value) 110 | if value ~= nil then 111 | return true 112 | else 113 | local message = "got nil" 114 | 115 | return false, message 116 | end 117 | end) 118 | end 119 | 120 | function Typer.optional(innerCheck) 121 | assert(typeof(innerCheck) == "table" and innerCheck[IS_SCHEMA]) 122 | 123 | local name = ("optional(%s)"):format(innerCheck.name) 124 | 125 | return makeSchema(name, function(value) 126 | if value == nil then 127 | return true 128 | else 129 | return innerCheck(value) 130 | end 131 | end) 132 | end 133 | 134 | function Typer.listOf(innerCheck) 135 | assert(typeof(innerCheck) == "table" and innerCheck[IS_SCHEMA]) 136 | 137 | local name = ("list(%s)"):format(innerCheck.name) 138 | 139 | return makeSchema(name, function(list) 140 | local actualType = typeof(list) 141 | 142 | if actualType ~= "table" then 143 | return false, ("got value of type %s"):format(actualType) 144 | end 145 | 146 | for key, value in pairs(list) do 147 | local keyType = typeof(key) 148 | 149 | if keyType ~= "number" then 150 | return false, ("got non-number key %s (of type %s)"):format(tostring(key), keyType) 151 | end 152 | 153 | local success, err = innerCheck(value) 154 | 155 | if not success then 156 | return false, ("had bad value at key %d, %s"):format(key, err) 157 | end 158 | end 159 | 160 | return true 161 | end) 162 | end 163 | 164 | function Typer.mapOf(keyCheck, valueCheck) 165 | assert(typeof(keyCheck) == "table" and keyCheck[IS_SCHEMA]) 166 | assert(typeof(valueCheck) == "table" and valueCheck[IS_SCHEMA]) 167 | 168 | local name = ("map(%s, %s)"):format(keyCheck.name, valueCheck.name) 169 | 170 | local tableTypeCheck = Typer.type("table") 171 | 172 | return makeSchema(name, function(map) 173 | local mapOk, mapErr = tableTypeCheck(map) 174 | 175 | if not mapOk then 176 | return false, mapErr 177 | end 178 | 179 | for key, value in pairs(map) do 180 | local keyOk, keyErr = keyCheck(key) 181 | 182 | if not keyOk then 183 | return false, ("had bad key %s: %s"):format(tostring(key), keyErr) 184 | end 185 | 186 | local valueOk, valueErr = keyCheck(value) 187 | 188 | if not valueOk then 189 | return false, ("had bad value %s at key %s"):format(tostring(value), tostring(key), valueErr) 190 | end 191 | end 192 | 193 | return true 194 | end) 195 | end 196 | 197 | function Typer.object(name, shape) 198 | assert(typeof(shape) == "table" and not shape[IS_SCHEMA]) 199 | 200 | return makeSchema(name, function(object) 201 | for key, valueCheck in pairs(shape) do 202 | local value = object[key] 203 | 204 | local ok, err = valueCheck(value) 205 | 206 | if not ok then 207 | return false, ("had bad key %s: %s"):format(tostring(key), err) 208 | end 209 | end 210 | 211 | for key, value in pairs(object) do 212 | if shape[key] == nil then 213 | return false, ("had unknown key %s with value %s"):format(tostring(key), tostring(value)) 214 | end 215 | end 216 | 217 | return true 218 | end) 219 | end 220 | 221 | return Typer -------------------------------------------------------------------------------- /src/common/actions/addItemsToPlayerInventory.lua: -------------------------------------------------------------------------------- 1 | local function addItemsToPlayerInventory(playerId, items) 2 | assert(typeof(playerId) == "string") 3 | assert(typeof(items) == "table" and #items == 0) 4 | 5 | return { 6 | type = script.Name, 7 | playerId = playerId, 8 | items = items, 9 | replicateTo = playerId, 10 | } 11 | end 12 | 13 | return addItemsToPlayerInventory -------------------------------------------------------------------------------- /src/common/actions/addItemsToWorld.lua: -------------------------------------------------------------------------------- 1 | local function addItemsToWorld(items) 2 | assert(typeof(items) == "table" and #items == 0) 3 | 4 | return { 5 | type = script.Name, 6 | items = items, 7 | } 8 | end 9 | 10 | return addItemsToWorld -------------------------------------------------------------------------------- /src/common/actions/addPlayer.lua: -------------------------------------------------------------------------------- 1 | local function addPlayer(playerId) 2 | assert(typeof(playerId) == "string") 3 | 4 | return { 5 | type = script.Name, 6 | playerId = playerId, 7 | } 8 | end 9 | 10 | return addPlayer -------------------------------------------------------------------------------- /src/common/actions/removeItemFromPlayerInventory.lua: -------------------------------------------------------------------------------- 1 | local function removeItemFromPlayerInventory(playerId, itemId) 2 | assert(typeof(playerId) == "string") 3 | assert(typeof(itemId) == "string") 4 | 5 | return { 6 | type = script.Name, 7 | playerId = playerId, 8 | itemId = itemId, 9 | replicateTo = playerId, 10 | } 11 | end 12 | 13 | return removeItemFromPlayerInventory -------------------------------------------------------------------------------- /src/common/actions/removeItemFromWorld.lua: -------------------------------------------------------------------------------- 1 | local function removeItemFromWorld(itemId) 2 | assert(typeof(itemId) == "string") 3 | 4 | return { 5 | type = script.Name, 6 | itemId = itemId, 7 | } 8 | end 9 | 10 | return removeItemFromWorld -------------------------------------------------------------------------------- /src/common/commonReducers/init.lua: -------------------------------------------------------------------------------- 1 | local subreducers = {} 2 | 3 | -- Bundling up all of our submodules makes it convenient to adjust reducers. 4 | for _, child in ipairs(script:GetChildren()) do 5 | if child:IsA("ModuleScript") then 6 | subreducers[child.Name] = require(child) 7 | end 8 | end 9 | 10 | return subreducers -------------------------------------------------------------------------------- /src/common/commonReducers/world.lua: -------------------------------------------------------------------------------- 1 | local Dictionary = require(script.Parent.Parent.Dictionary) 2 | local None = require(script.Parent.Parent.None) 3 | 4 | local function world(state, action) 5 | state = state or {} 6 | 7 | if action.type == "addItemsToWorld" then 8 | return Dictionary.join(state, action.items) 9 | elseif action.type == "removeItemFromWorld" then 10 | return Dictionary.join(state, { 11 | [action.itemId] = None, 12 | }) 13 | end 14 | 15 | return state 16 | end 17 | 18 | return world -------------------------------------------------------------------------------- /src/common/objects/Item.lua: -------------------------------------------------------------------------------- 1 | local HttpService = game:GetService("HttpService") 2 | 3 | local function getId() 4 | return HttpService:GenerateGUID(false) 5 | end 6 | 7 | local Item = {} 8 | 9 | function Item.new() 10 | local self = { 11 | id = getId(), 12 | name = "Unnamed Item", 13 | color = Color3.new(1, 0, 0), 14 | position = Vector3.new(0, 0, 0), 15 | } 16 | 17 | return self 18 | end 19 | 20 | return Item -------------------------------------------------------------------------------- /src/server/ServerApi.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local ApiSpec = require(ReplicatedStorage.Modules.RDC.ApiSpec) 4 | 5 | local ServerApi = {} 6 | ServerApi.prototype = {} 7 | ServerApi.__index = ServerApi.prototype 8 | 9 | ServerApi.AllPlayers = newproxy(true) 10 | 11 | function ServerApi.create(handlers) 12 | assert(typeof(handlers) == "table") 13 | 14 | local self = {} 15 | 16 | setmetatable(self, ServerApi) 17 | 18 | local remotes = Instance.new("Folder") 19 | remotes.Name = "Events" 20 | 21 | for name, endpoint in pairs(ApiSpec.fromClient) do 22 | local remote = Instance.new("RemoteEvent") 23 | remote.Name = "fromClient-" .. name 24 | remote.Parent = remotes 25 | 26 | local handler = handlers[name] 27 | 28 | if handler == nil then 29 | error(("Need to implement server handler for %q"):format(name), 2) 30 | end 31 | 32 | remote.OnServerEvent:Connect(function(player, ...) 33 | assert(typeof(player) == "Instance" and player:IsA("Player")) 34 | 35 | endpoint.arguments(...) 36 | 37 | handler(player, ...) 38 | end) 39 | end 40 | 41 | for name, endpoint in pairs(ApiSpec.fromServer) do 42 | local remote = Instance.new("RemoteEvent") 43 | remote.Name = "fromServer-" .. name 44 | remote.Parent = remotes 45 | 46 | self[name] = function(_, player, ...) 47 | endpoint.arguments(...) 48 | 49 | if player == ServerApi.AllPlayers then 50 | remote:FireAllClients(...) 51 | else 52 | assert(typeof(player) == "Instance" and player:IsA("Player")) 53 | 54 | remote:FireClient(player, ...) 55 | end 56 | end 57 | end 58 | 59 | for name in pairs(handlers) do 60 | if ApiSpec.fromClient[name] == nil then 61 | error(("Invalid handler %q specified!"):format(name), 2) 62 | end 63 | end 64 | 65 | remotes.Parent = ReplicatedStorage 66 | self.remotes = remotes 67 | 68 | return self 69 | end 70 | 71 | function ServerApi.prototype:destroy() 72 | self.remotes:Destroy() 73 | end 74 | 75 | return ServerApi -------------------------------------------------------------------------------- /src/server/getRandomItemName.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This file isn't actually interesting or novel, I just felt like writing a 3 | name generator that would create nonsensical item names so that I could 4 | identify individual items for debugging. 5 | ]] 6 | 7 | local modifiers = { 8 | "Rusty", 9 | "Evil", 10 | "Dull", 11 | "Moldy", 12 | "Sparkly", 13 | "Broken", 14 | "Soggy", 15 | "Unyielding", 16 | "Balanced", 17 | "Rare", 18 | "Worn", 19 | "Puzzling", 20 | "Sticky", 21 | } 22 | 23 | local materials = { 24 | "Granite", 25 | "Wooden", 26 | "Leather", 27 | "Iron", 28 | "Steel", 29 | "Plastic", 30 | "Cloth", 31 | } 32 | 33 | local subjects = { 34 | "Sword", 35 | "Spear", 36 | "Mace", 37 | "Greatsword", 38 | "Shield", 39 | "Boots", 40 | "Helmet", 41 | "Chestplate", 42 | "Pauldrons", 43 | "Gauntlets", 44 | "Mask", 45 | "Hood", 46 | "Robe", 47 | "Loaf of Bread", 48 | } 49 | 50 | local sources = { 51 | "Time", 52 | "Power", 53 | "Darkness", 54 | "Greatness", 55 | "Enigmas", 56 | "Justice", 57 | "Disruption", 58 | } 59 | 60 | local function chooseFrom(list) 61 | return list[math.random(1, #list)] 62 | end 63 | 64 | local function getRandomItemName() 65 | local material = chooseFrom(materials) 66 | local subject = chooseFrom(subjects) 67 | 68 | local name = string.format("%s %s", material, subject) 69 | 70 | if math.random(1, 4) == 1 then 71 | local modifier = chooseFrom(modifiers) 72 | name = string.format("%s %s", modifier, name) 73 | end 74 | 75 | if math.random(1, 7) == 1 then 76 | local source = chooseFrom(sources) 77 | name = string.format("%s of %s", name, source) 78 | end 79 | 80 | return name 81 | end 82 | 83 | return getRandomItemName -------------------------------------------------------------------------------- /src/server/init.server.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Serves as the entry-point to the server code. 3 | 4 | This file contains a bit of ceremony to set up hot-reloading, which is only 5 | used during development. 6 | ]] 7 | 8 | -- There's probably a more elegant approach to this that involves waiting on 9 | -- GetPropertyChangedSignal("Parent") instead of this loop. 10 | repeat 11 | wait() 12 | until script.Parent ~= nil 13 | 14 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 15 | 16 | local HotReloadServer = require(ReplicatedStorage.HotReloadServer) 17 | 18 | local savedState = HotReloadServer.getSavedState() 19 | local savedActions = {} 20 | 21 | if savedState ~= nil then 22 | savedActions = savedState.savedActions 23 | end 24 | 25 | local context = { 26 | running = true, 27 | destructors = {}, 28 | savedActions = savedActions, 29 | wasReloaded = savedState ~= nil, 30 | } 31 | 32 | HotReloadServer.start({ 33 | -- The order of objects to watch is important, otherwise a hot-reloaded 34 | -- server might start running before the modules it depends on are reloaded. 35 | watch = { 36 | game:GetService("ReplicatedStorage").Modules, 37 | game:GetService("StarterPlayer").StarterPlayerScripts.RDC, 38 | game:GetService("ServerScriptService").RDC, 39 | }, 40 | 41 | beforeUnload = function() 42 | context.running = false 43 | 44 | for _, destructor in ipairs(context.destructors) do 45 | local ok, result = pcall(destructor) 46 | 47 | if not ok then 48 | warn("Failure during destruction: " .. result) 49 | end 50 | end 51 | 52 | return { 53 | savedActions = context.savedActions, 54 | } 55 | end, 56 | 57 | afterReload = function() 58 | -- This function is sort of vestigial now. 59 | -- It's used to run code after the new server function has started 60 | -- running, and was previously used to respawn all players. 61 | 62 | -- Now that the client code is hot-reloaded without respawning, there's 63 | -- no need for this! 64 | end, 65 | }) 66 | 67 | local main = require(script.main) 68 | main(context) -------------------------------------------------------------------------------- /src/server/main.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local Players = game:GetService("Players") 3 | 4 | local Modules = ReplicatedStorage.Modules 5 | 6 | local Rodux = require(Modules.Rodux) 7 | 8 | -- The Rodux DevTools aren't available yet! Check the README for more details. 9 | -- local RoduxVisualizer = require(Modules.RoduxVisualizer) 10 | 11 | local commonReducers = require(Modules.RDC.commonReducers) 12 | local Dictionary = require(Modules.RDC.Dictionary) 13 | local Item = require(Modules.RDC.objects.Item) 14 | 15 | -- These imports are pretty darn verbose. 16 | local addPlayer = require(Modules.RDC.actions.addPlayer) 17 | local addItemsToPlayerInventory = require(Modules.RDC.actions.addItemsToPlayerInventory) 18 | local removeItemFromPlayerInventory = require(Modules.RDC.actions.removeItemFromPlayerInventory) 19 | local addItemsToWorld = require(Modules.RDC.actions.addItemsToWorld) 20 | local removeItemFromWorld = require(Modules.RDC.actions.removeItemFromWorld) 21 | 22 | local serverReducers = require(script.Parent.serverReducers) 23 | local ServerApi = require(script.Parent.ServerApi) 24 | local networkMiddleware = require(script.Parent.networkMiddleware) 25 | local getRandomItemName = require(script.Parent.getRandomItemName) 26 | 27 | return function(context) 28 | local reducer = Rodux.combineReducers(Dictionary.join(commonReducers, serverReducers)) 29 | 30 | local api 31 | 32 | --[[ 33 | This function contains our custom replication logic for Rodux actions. 34 | 35 | Using the Redux pattern as a way to sychronize replicated data is a new 36 | idea. Vocksel introduced the idea to me, and I used this project partly 37 | as a test bed to try it out. 38 | ]] 39 | local function replicate(action, beforeState, afterState) 40 | -- Create a version of each action that's explicitly flagged as 41 | -- replicated so that clients can handle them explicitly. 42 | local replicatedAction = Dictionary.join(action, { 43 | replicated = true, 44 | }) 45 | 46 | -- This is an action that everyone should see! 47 | if action.replicateBroadcast then 48 | return api:storeAction(ServerApi.AllPlayers, replicatedAction) 49 | end 50 | 51 | -- This is an action that we want a specific player to see. 52 | if action.replicateTo ~= nil then 53 | local player = Players:GetPlayerByUserId(action.replicateTo) 54 | 55 | if player == nil then 56 | return 57 | end 58 | 59 | return api:storeAction(player, replicatedAction) 60 | end 61 | 62 | -- We should probably replicate any actions that modify data shared 63 | -- between the client and server. 64 | for key in pairs(commonReducers) do 65 | if beforeState[key] ~= afterState[key] then 66 | return api:storeAction(ServerApi.AllPlayers, replicatedAction) 67 | end 68 | end 69 | 70 | return 71 | end 72 | 73 | --[[ 74 | For hot-reloading, we want to save a list of every action that gets run 75 | through the store. This lets us iterate on our reducers, but otherwise 76 | keep any state we want across reloads. 77 | ]] 78 | local function saveActionsMiddleware(nextDispatch) 79 | return function(action) 80 | table.insert(context.savedActions, action) 81 | 82 | return nextDispatch(action) 83 | end 84 | end 85 | 86 | -- This is a case where the simplicify of reducers shines! 87 | -- We produce the state that this store should start at based on the actions 88 | -- that the previous store had executed. 89 | local initialState = nil 90 | for _, action in ipairs(context.savedActions) do 91 | initialState = reducer(initialState, action) 92 | end 93 | 94 | -- local devTools = RoduxVisualizer.createDevTools({ 95 | -- mode = RoduxVisualizer.Mode.Plugin, 96 | -- }) 97 | 98 | local middleware = { 99 | -- Our minimal middleware to save actions to our context. 100 | saveActionsMiddleware, 101 | 102 | -- Middleware to replicate actions to the client, using the replicate 103 | -- callback defined above. 104 | networkMiddleware(replicate), 105 | 106 | -- Rodux has a built-in logger middleware to print to the console 107 | -- whenever actions are dispatched to show the store. 108 | -- Rodux.loggerMiddleware, 109 | 110 | -- Once the Rodux DevTools are available, this will be revisited! 111 | -- devTools.middleware, 112 | } 113 | 114 | local store = Rodux.Store.new(reducer, initialState, middleware) 115 | 116 | -- Construct our ServerApi, which creates RemoteEvent objects for our 117 | -- clients to listen to. 118 | api = ServerApi.create({ 119 | clientStart = function(player) 120 | store:dispatch(addPlayer(tostring(player.UserId))) 121 | 122 | -- We need to make sure not to replicate anything secret! 123 | local state = store:getState() 124 | local commonState = {} 125 | 126 | for key, value in pairs(state) do 127 | if commonReducers[key] ~= nil then 128 | commonState[key] = value 129 | end 130 | end 131 | 132 | api:initialStoreState(player, commonState) 133 | end, 134 | 135 | pickUpItem = function(player, itemId) 136 | local state = store:getState() 137 | local item = state.world[itemId] 138 | 139 | if item == nil then 140 | warn("Player can't pick up item " .. itemId) 141 | return 142 | end 143 | 144 | store:dispatch(removeItemFromWorld(itemId)) 145 | store:dispatch(addItemsToPlayerInventory(tostring(player.UserId), { 146 | [itemId] = item, 147 | })) 148 | end, 149 | 150 | dropItem = function(player, itemId) 151 | local playerId = tostring(player.UserId) 152 | local state = store:getState() 153 | local inventory = state.playerInventories[playerId] 154 | 155 | if inventory == nil then 156 | warn("Couldn't find player inventory " .. playerId) 157 | return 158 | end 159 | 160 | local item = inventory[itemId] 161 | 162 | if item == nil then 163 | warn("Player can't drop item " .. itemId) 164 | return 165 | end 166 | 167 | local character = player.Character 168 | 169 | if character == nil then 170 | warn("Can't drop item for player, no character: " .. playerId) 171 | return 172 | end 173 | 174 | local root = character:FindFirstChild("HumanoidRootPart") 175 | 176 | if root == nil then 177 | warn("No HumanoidRootPart in character from " .. playerId) 178 | return 179 | end 180 | 181 | -- That was an exhausting set of checks, oof. 182 | 183 | local newPosition = root.Position + root.CFrame.lookVector * 4 184 | local newItem = Dictionary.join(item, { 185 | position = newPosition, 186 | }) 187 | 188 | store:dispatch(removeItemFromPlayerInventory(playerId, itemId)) 189 | store:dispatch(addItemsToWorld({ 190 | [itemId] = newItem, 191 | })) 192 | end, 193 | }) 194 | 195 | -- The hot-reloading shim has a place for us to stick destructors, since we 196 | -- need to clean up everything on the server before unloading. 197 | table.insert(context.destructors, function() 198 | store:destruct() 199 | end) 200 | 201 | table.insert(context.destructors, function() 202 | api:destroy() 203 | end) 204 | 205 | -- If we've hot-reloaded, we don't want to spawn new objects into the world 206 | -- since the reloaded state should already have them. 207 | if not context.wasReloaded then 208 | local worldItems = {} 209 | for _ = 1, 15 do 210 | local item = Item.new() 211 | local x = math.random(-20, 20) 212 | local z = math.random(-20, 20) 213 | 214 | item.position = Vector3.new(x, 1.5, z) 215 | item.name = getRandomItemName() 216 | 217 | worldItems[item.id] = item 218 | end 219 | 220 | store:dispatch(addItemsToWorld(worldItems)) 221 | end 222 | 223 | print("Server started!") 224 | end -------------------------------------------------------------------------------- /src/server/networkMiddleware.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This is a fairly abstract middleware that enables you to replicate events 3 | to another store via whatever mechanism. It doesn't impose any sort of 4 | filtering or propose any solutions, those are handled in main. 5 | ]] 6 | 7 | local function networkMiddleware(replicate) 8 | return function(nextDispatch, store) 9 | return function(action) 10 | local beforeState = store:getState() 11 | local result = nextDispatch(action) 12 | local afterState = store:getState() 13 | 14 | replicate(action, beforeState, afterState) 15 | 16 | return result 17 | end 18 | end 19 | end 20 | 21 | return networkMiddleware -------------------------------------------------------------------------------- /src/server/serverReducers/init.lua: -------------------------------------------------------------------------------- 1 | local subreducers = {} 2 | 3 | -- Bundle all of our submodules automatically. 4 | for _, child in ipairs(script:GetChildren()) do 5 | if child:IsA("ModuleScript") then 6 | subreducers[child.Name] = require(child) 7 | end 8 | end 9 | 10 | return subreducers -------------------------------------------------------------------------------- /src/server/serverReducers/playerInventories.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local Dictionary = require(ReplicatedStorage.Modules.RDC.Dictionary) 4 | local None = require(ReplicatedStorage.Modules.RDC.None) 5 | 6 | local function playerInventories(state, action) 7 | state = state or {} 8 | 9 | if action.type == "addPlayer" then 10 | local existingPlayer = state[action.playerId] 11 | 12 | if existingPlayer ~= nil then 13 | return state 14 | end 15 | 16 | return Dictionary.join(state, { 17 | [action.playerId] = {}, 18 | }) 19 | elseif action.type == "addItemsToPlayerInventory" then 20 | local inventory = state[action.playerId] 21 | 22 | if inventory == nil then 23 | local message = ("No player with the ID %q"):format(tostring(action.playerId)) 24 | warn(message) 25 | 26 | return state 27 | end 28 | 29 | return Dictionary.join(state, { 30 | [action.playerId] = Dictionary.join(inventory, action.items), 31 | }) 32 | elseif action.type == "removeItemFromPlayerInventory" then 33 | local inventory = state[action.playerId] 34 | 35 | if inventory == nil then 36 | local message = ("No player with the ID %q"):format(tostring(action.playerId)) 37 | warn(message) 38 | 39 | return state 40 | end 41 | 42 | return Dictionary.join(state, { 43 | [action.playerId] = Dictionary.join(inventory, { 44 | [action.itemId] = None, 45 | }) 46 | }) 47 | end 48 | 49 | return state 50 | end 51 | 52 | return playerInventories --------------------------------------------------------------------------------