├── selene.toml ├── .luaurc ├── README.md ├── docs ├── .vitepress │ ├── cache │ │ └── deps │ │ │ ├── package.json │ │ │ ├── vue.js.map │ │ │ ├── _metadata.json │ │ │ └── vue.js │ ├── theme │ │ ├── index.ts │ │ └── style.css │ └── config.ts ├── public │ └── logo.png ├── Benchmarks │ ├── BetterReplicationBench.rbxl │ ├── Comparison.md │ └── Overview.md ├── Learn │ ├── Why.md │ ├── Installation.md │ ├── Disabling-Default-Replication.md │ ├── Client-Tick.md │ ├── Snapshots.md │ ├── Serialization.md │ ├── Usage.md │ └── Interpolation-Buffer.md ├── index.md └── API │ ├── ChronoClient.md │ ├── RenderCache.md │ ├── Snapshots.md │ ├── Interpolation-Buffer.md │ ├── Config.md │ ├── ChronoServer.md │ ├── Character.md │ └── NpcRegistry.md ├── default.project.json ├── .vscode ├── settings.json └── extensions.json ├── test ├── ServerRuntime.luau ├── ClientRuntime.client.luau ├── CharacterTest.luau ├── NpcTest.server.luau └── Debugger.luau ├── .gitignore ├── package.json ├── wally.lock ├── rokit.toml ├── why-is-it-named-chrono.md ├── wally.toml ├── benchmarks ├── RandomMovement.client.luau ├── Results.md └── TestNPC.server.luau ├── src ├── Shared │ ├── Rigs │ │ └── CreateCharacter.luau │ ├── InterpolationMath.luau │ ├── Config.luau │ ├── MakeNetworkable.luau │ ├── Character.luau │ ├── Grid.luau │ ├── Snapshots.luau │ ├── Signal.luau │ └── NpcRegistry.luau ├── events.blink ├── Client │ ├── InterpolationBuffer.luau │ ├── RenderCache.luau │ └── Replicate.luau ├── init.luau ├── Events │ ├── Client.luau │ └── Server.luau └── Server │ └── Replicate.luau ├── roblox-test.project.json ├── .github ├── FUNDING.yml └── workflows │ ├── deploy.yml │ └── release.yml └── LICENSE /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox" 2 | -------------------------------------------------------------------------------- /.luaurc: -------------------------------------------------------------------------------- 1 | { 2 | "languageMode": "strict" 3 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chrono 2 | 3 | ### Docs 4 | https://parihsz.github.io/Chrono/ -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module" 3 | } 4 | -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Parihsz/Chrono/HEAD/docs/public/logo.png -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrono", 3 | "tree": { 4 | "$path": "src" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "luau-lsp.sourcemap.rojoProjectFile": "roblox-test.project.json" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["JohnnyMorganz.luau-lsp", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/Benchmarks/BetterReplicationBench.rbxl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Parihsz/Chrono/HEAD/docs/Benchmarks/BetterReplicationBench.rbxl -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vue.js.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "sources": [], 4 | "sourcesContent": [], 5 | "mappings": "", 6 | "names": [] 7 | } 8 | -------------------------------------------------------------------------------- /test/ServerRuntime.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | require(ReplicatedStorage.Packages.chrono).Start() 4 | require(script.Parent.CharacterTest) 5 | -------------------------------------------------------------------------------- /test/ClientRuntime.client.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | require(ReplicatedStorage.Packages.chrono).Start() 4 | require(script.Parent.CharacterTest) 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project place file 2 | /custom-character-replication.rbxlx 3 | 4 | # Roblox Studio lock files 5 | /*.rbxlx.lock 6 | /*.rbxl.lock 7 | 8 | sourcemap.json 9 | node_modules/ 10 | 11 | # Development 12 | DevPackages 13 | -------------------------------------------------------------------------------- /docs/Learn/Why.md: -------------------------------------------------------------------------------- 1 | # Why use Chrono? 2 | Roblox's replication is slow - physics is replicated at 20hz, and there is a massive interpolation buffer designed for mobile people. Chrono aims to solve both these issues, while also providing lower bandwidth than roblox's solution. 3 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | import type { Theme } from 'vitepress' 3 | import DefaultTheme from 'vitepress/theme' 4 | import './style.css' 5 | 6 | export default { 7 | extends: DefaultTheme, 8 | enhanceApp({ app, router, siteData }) { 9 | } 10 | } satisfies Theme -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "docs:dev": "vitepress dev docs", 5 | "docs:build": "vitepress build docs", 6 | "docs:preview": "vitepress preview docs" 7 | }, 8 | "devDependencies": { 9 | "vitepress": "^1.6.4" 10 | }, 11 | "dependencies": { 12 | "rbxtheme": "^1.4.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/Learn/Installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | outline: deep 4 | --- 5 | # Installation 6 | 7 | For **Rojo** users: 8 | In your `wally.toml`, add the following line to your dependencies. 9 | 10 | ```toml 11 | chrono = "parihsz/chrono@1.3.0" 12 | ``` 13 | 14 | For **Studio** users: 15 | Download the latest rbxl file from Github Releases 16 | -------------------------------------------------------------------------------- /wally.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Wally. 2 | # It is not intended for manual editing. 3 | registry = "test" 4 | 5 | [[package]] 6 | name = "parihsz/chrono" 7 | version = "1.2.4" 8 | dependencies = [["iris", "sirmallard/iris@2.5.0"]] 9 | 10 | [[package]] 11 | name = "sirmallard/iris" 12 | version = "2.5.0" 13 | dependencies = [] 14 | -------------------------------------------------------------------------------- /rokit.toml: -------------------------------------------------------------------------------- 1 | # This file lists tools managed by Rokit, a toolchain manager for Roblox projects. 2 | # For more information, see https://github.com/rojo-rbx/rokit 3 | 4 | # New tools can be added by running `rokit add ` in a terminal. 5 | 6 | [tools] 7 | rojo = "rojo-rbx/rojo@7.5.1" 8 | wally = "UpliftGames/wally@0.3.2" 9 | blink = "1Axen/blink@0.18.0" 10 | -------------------------------------------------------------------------------- /why-is-it-named-chrono.md: -------------------------------------------------------------------------------- 1 | image 2 |
3 | image 4 |
5 | image 6 | -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "parihsz/chrono" 3 | description = "Custom Character Replication" 4 | version = "1.3.0" 5 | registry = "https://github.com/UpliftGames/wally-index" 6 | exclude = [ 7 | ".vscode", 8 | "rokit.toml", 9 | ".luaurc", 10 | "sourcemap.json", 11 | "selene.toml", 12 | "README.md", 13 | "docs", 14 | ".gitignore", 15 | "node_modules", 16 | ] 17 | realm = "shared" 18 | 19 | [dev-dependencies] 20 | iris = "sirmallard/iris@2.5.0" 21 | -------------------------------------------------------------------------------- /benchmarks/RandomMovement.client.luau: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | local RunService = game:GetService("RunService") 3 | 4 | local PlayerLocal = Players.LocalPlayer 5 | 6 | local ENABLED = false 7 | 8 | if not ENABLED then 9 | return 10 | end 11 | 12 | task.wait(2) 13 | local direction = Random.new():NextUnitVector() 14 | RunService.RenderStepped:Connect(function() 15 | PlayerLocal:Move(direction) 16 | end) 17 | 18 | while task.wait(1) do 19 | direction = -direction 20 | end 21 | -------------------------------------------------------------------------------- /src/Shared/Rigs/CreateCharacter.luau: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | 3 | return function(player: Player, userId: number): { Model: Model } 4 | local rig = {} 5 | 6 | if userId < -1 then 7 | userId = 1242803262 8 | end 9 | 10 | local humanoidDescription = Players:GetHumanoidDescriptionFromUserId(userId) 11 | local model = Players:CreateHumanoidModelFromDescription(humanoidDescription, Enum.HumanoidRigType.R15) 12 | model.Parent = workspace 13 | 14 | rig.Model = model 15 | 16 | return rig 17 | end 18 | -------------------------------------------------------------------------------- /roblox-test.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chrono-dev", 3 | 4 | "emitLegacyScripts": false, 5 | 6 | "tree": { 7 | "$className": "DataModel", 8 | 9 | "ReplicatedStorage": { 10 | "Packages": { 11 | "$className": "Folder", 12 | 13 | "chrono": { 14 | "$path": "default.project.json" 15 | } 16 | }, 17 | 18 | "DevPackages": { "$path": "DevPackages"}, 19 | "Benches": { "$path": "benchmarks"}, 20 | 21 | "$path": "test" 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/CharacterTest.luau: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | local RunService = game:GetService("RunService") 4 | local chrono = require(ReplicatedStorage.Packages.chrono) 5 | 6 | if not chrono.Config.ENABLE_CUSTOM_CHARACTERS then 7 | return nil 8 | end 9 | 10 | if RunService:IsServer() then 11 | Players.PlayerAdded:Connect(function(player) 12 | player.CharacterAdded:Connect(function(char: Model) 13 | chrono.Character.SetCharacter(player, char) 14 | end) 15 | end) 16 | else 17 | chrono.Character.CharacterAdded:Connect(function(player, model, data) 18 | if player == Players.LocalPlayer then 19 | Players.LocalPlayer.Character = model 20 | workspace.CurrentCamera.CameraSubject = model:FindFirstChildWhichIsA("Humanoid") or model 21 | end 22 | end) 23 | end 24 | return nil 25 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/_metadata.json: -------------------------------------------------------------------------------- 1 | { 2 | "hash": "5d6650ca", 3 | "configHash": "18710a41", 4 | "lockfileHash": "299412d2", 5 | "browserHash": "1314121b", 6 | "optimized": { 7 | "vue": { 8 | "src": "../../../../node_modules/vue/dist/vue.runtime.esm-bundler.js", 9 | "file": "vue.js", 10 | "fileHash": "e2848510", 11 | "needsInterop": false 12 | }, 13 | "vitepress > @vue/devtools-api": { 14 | "src": "../../../../node_modules/@vue/devtools-api/dist/index.js", 15 | "file": "vitepress___@vue_devtools-api.js", 16 | "fileHash": "4a61698b", 17 | "needsInterop": false 18 | }, 19 | "vitepress > @vueuse/core": { 20 | "src": "../../../../node_modules/@vueuse/core/index.mjs", 21 | "file": "vitepress___@vueuse_core.js", 22 | "fileHash": "0f115526", 23 | "needsInterop": false 24 | } 25 | }, 26 | "chunks": { 27 | "chunk-HVR2FF6M": { 28 | "file": "chunk-HVR2FF6M.js" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Parihsz] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /src/Shared/InterpolationMath.luau: -------------------------------------------------------------------------------- 1 | local Snapshots = require(script.Parent.Snapshots) 2 | 3 | local function Hermite(p0: Vector3, p1: Vector3, v0: Vector3, v1: Vector3, t: number, dt: number): Vector3 4 | v0 = v0 or Vector3.zero 5 | v1 = v1 or Vector3.zero 6 | if not dt or dt == 0 then 7 | return p0:Lerp(p1, t) 8 | end 9 | 10 | local t2 = t * t 11 | local t3 = t2 * t 12 | local h00 = 2 * t3 - 3 * t2 + 1 13 | local h10 = t3 - 2 * t2 + t 14 | local h01 = -2 * t3 + 3 * t2 15 | local h11 = t3 - t2 16 | 17 | return p0 * h00 + v0 * dt * h10 + p1 * h01 + v1 * dt * h11 18 | end 19 | 20 | local function VelocityAt(latest: Snapshots.SnapshotData?, t: number, cframe: CFrame): Vector3 21 | if not latest then 22 | return Vector3.zero 23 | end 24 | local dt = t - latest.t 25 | if dt <= 1e-6 then 26 | return Vector3.zero 27 | end 28 | return (cframe.Position - latest.value.Position) / dt 29 | end 30 | 31 | return { 32 | Hermite = Hermite, 33 | VelocityAt = VelocityAt, 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | pages: write 14 | id-token: write 15 | environment: 16 | name: github-pages 17 | url: ${{ steps.deployment.outputs.page_url }} 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | with: 22 | fetch-depth: 0 23 | 24 | - uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: npm 28 | 29 | - run: npm ci 30 | 31 | - name: Build Docs 32 | run: npm run docs:build 33 | 34 | - name: Configure Pages 35 | uses: actions/configure-pages@v4 36 | 37 | - name: Upload Pages Artifact 38 | uses: actions/upload-pages-artifact@v3 39 | with: 40 | path: docs/.vitepress/dist 41 | 42 | - name: Deploy to GitHub Pages 43 | id: deployment 44 | uses: actions/deploy-pages@v4 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Parihsz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /benchmarks/Results.md: -------------------------------------------------------------------------------- 1 | ## Results 2 | 3 | | Scenario | Send (KB/s) | Recv (KB/s) | 4 | |------------------------------------------------------------------|------------:|------------:| 5 | | Chrono w/ Default Replication Disabled (20 Hz, 10 players) | 3.53 | 6.44 | 6 | | Chrono (20 Hz, 10 players) | 3.49 | 15.18 | 7 | | BetterReplication (20 Hz, 10 players) | 4.45 | 20.96 | 8 | | Chrono (150 parts, random motion) | 3.72 | 42.97 | 9 | | Roblox (150 parts, random motion) | 110.29 | 412.30 | 10 | | Chrono (150 NPCs, following player) | 1.94 | 67.70 | 11 | | Roblox (150 NPCs, following player) | 13.25 | 242.19 | 12 | 13 | --- 14 | [Chrono & Roblox NPC/Part Benchmark](https://github.com/parihsz/chrono/raw/master/benchmarks/TestNPC.server.luau)\ 15 | [Download BetterReplication Benchmark RBXL](https://github.com/parihsz/chrono/raw/master/docs/Benchmarks/BetterReplicationBench.rbxl) 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/Benchmarks/Comparison.md: -------------------------------------------------------------------------------- 1 | ## Results 2 | 3 | | Scenario | Send (KB/s) | Recv (KB/s) | 4 | |------------------------------------------------------------------|------------:|------------:| 5 | | Chrono w/ Default Replication Disabled (20 Hz, 10 players) | 3.53 | 6.44 | 6 | | Chrono (20 Hz, 10 players) | 3.49 | 15.18 | 7 | | BetterReplication (20 Hz, 10 players) | 4.45 | 20.96 | 8 | | Chrono (150 parts, random motion) | 3.72 | 42.97 | 9 | | Roblox (150 parts, random motion) | 110.29 | 412.30 | 10 | | Chrono (150 NPCs, following player) | 1.94 | 67.70 | 11 | | Roblox (150 NPCs, following player) | 13.25 | 242.19 | 12 | 13 | --- 14 | [Chrono & Roblox NPC/Part Benchmark](https://github.com/parihsz/chrono/raw/master/benchmarks/TestNPC.server.luau)\ 15 | [Download BetterReplication Benchmark RBXL](https://github.com/parihsz/chrono/raw/master/docs/Benchmarks/BetterReplicationBench.rbxl) 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | 4 | hero: 5 | name: Chrono 6 | text: Custom Character Replication 7 | tagline: Precision in motion, custom by design. The definitive physics replication library. 8 | image: "./logo.png" 9 | actions: 10 | - theme: brand 11 | text: Learn 12 | link: /Learn/Why 13 | - theme: alt 14 | text: API Reference 15 | link: /API/ChronoClient 16 | - theme: alt 17 | text: Benchmarks 18 | link: /Benchmarks/Overview 19 | - theme: alt 20 | text: Github 21 | link: https://github.com/Parihsz/Chrono 22 | 23 | features: 24 | - icon: 🌐 25 | title: Dynamic Interpolation 26 | details: Lower interpolation buffer for low latency players using statistics 27 | - icon: ⚡ 28 | title: Lightning Performance 29 | details: Fast buffer serdes, packet batching, coordinate encoding and proximity based optimizations 30 | - icon: 👾 31 | title: NPC Support 32 | details: Register and deregister any entities easily to be custom replicated 33 | - icon: ⚙️ 34 | title: Full Configurability 35 | details: Support for custom characters, disabled physics replication and custom animation replication 36 | --- 37 | 38 | -------------------------------------------------------------------------------- /docs/Benchmarks/Overview.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | 3 | Here I am comparing multiple replication frameworks in Roblox, including: 4 | 5 | - **Chrono** 6 | - **BetterReplication** (another custom replication library) 7 | - **Roblox Default Replication** 8 | 9 | The benchmarks measure **both Send and Receive (Recv) bandwidth**: 10 | 11 | - **Recv (Server → Client):** 12 | - **Send (Client → Server):** 13 | --- 14 | 15 | ## Benchmarked Scenarios 16 | 17 | The following scenarios were tested in this benchmark: 18 | 19 | ### 1. **Player Replication** 20 | - Chrono **with Default Replication Disabled** (20 Hz, 10 players in proximity) 21 | - Chrono **with Default Replication** (20 Hz, 10 players in proximity) 22 | - BetterReplication (20 Hz, 10 players in proximity) 23 | :::info 24 | BetterReplication does not support disabling default replication 25 | ::: 26 | 27 | --- 28 | BetterReplication is for players only, so it will not be measured in the following benchmarks 29 | 30 | ### 2. **Moving Part Replication** 31 | - Chrono (150 parts, random motion) 32 | - Roblox Default Replication (150 parts, random motion) 33 | 34 | --- 35 | 36 | ### 3. **NPC Replication** 37 | - Chrono (150 NPCs, following player) 38 | - Roblox Default Replication (150 NPCs, following player) 39 | --- 40 | -------------------------------------------------------------------------------- /test/NpcTest.server.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | 3 | local NpcRegistry = require(ReplicatedStorage.Packages.chrono.Shared.NpcRegistry) 4 | 5 | local ENABLED = true 6 | 7 | if not ENABLED then 8 | return 9 | end 10 | 11 | local model1 = Instance.new("Model", workspace) 12 | local part = Instance.new("Part", model1) 13 | model1.PrimaryPart = part 14 | part.Size = Vector3.new(2, 5, 2) 15 | part.Position = Vector3.new(10, 10, 10) 16 | local id2 = NpcRegistry.Register(model1, "DEFAULT", nil, true) 17 | 18 | local player = game:GetService("Players").PlayerAdded:Wait() 19 | task.wait(1) 20 | local model = player.Character or player.CharacterAdded:Wait() 21 | model.Archivable = true 22 | 23 | local clone = model:Clone() 24 | clone.Parent = workspace 25 | 26 | local id = NpcRegistry.Register(clone, "DEFAULT", nil, true) 27 | 28 | NpcRegistry.SetNetworkOwner(id, player) 29 | 30 | NpcRegistry.SetPosition(id2, CFrame.new(4, 15, 0)) 31 | NpcRegistry.SetPosition(id, CFrame.new(0, 10, 0)) 32 | 33 | task.wait(4) 34 | print("Starting Toggle Loop") 35 | while true do 36 | task.wait(4) 37 | print("Set Player") 38 | NpcRegistry.SetNetworkOwner(id, player) 39 | task.wait(4) 40 | print("Set Server") 41 | NpcRegistry.SetNetworkOwner(id, nil) 42 | end 43 | -------------------------------------------------------------------------------- /src/Shared/Config.luau: -------------------------------------------------------------------------------- 1 | export type NPC_CONFIG = { 2 | BUFFER: number, 3 | TICK_RATE: number, 4 | } 5 | 6 | return { 7 | MIN_BUFFER = 0.09, 8 | MAX_BUFFER = 0.5, 9 | TICK_RATE = 1 / 20, 10 | 11 | PROXIMITY = 100, 12 | 13 | ENABLE_CUSTOM_CHARACTERS = false, -- this will disable default roblox replication as well. 14 | DISABLE_DEFAULT_REPLICATION = true, -- this is if you want to disable the default roblox replication, but still use their controller 15 | 16 | SEND_FULL_ROTATION = false, 17 | 18 | NPC_MODELS = {}, 19 | 20 | NPC_TYPES = { 21 | DEFAULT = { 22 | TICK_RATE = 1 / 30, 23 | BUFFER = 0.1, 24 | }, 25 | }, 26 | 27 | PLAYER_MODELS = {}, 28 | 29 | --note that warnings are mostly non fatal 30 | --this will be set to false to optimize output by default, only enable if you're experiencing bugs with the system 31 | SHOW_WARNINGS = false, 32 | } :: { 33 | MIN_BUFFER: number, 34 | MAX_BUFFER: number, 35 | TICK_RATE: number, 36 | 37 | PROXIMITY: number, 38 | 39 | ENABLE_CUSTOM_CHARACTERS: boolean, 40 | DISABLE_DEFAULT_REPLICATION: boolean, 41 | 42 | SEND_FULL_ROTATION: boolean, 43 | 44 | NPC_MODELS: { [string]: Model }, 45 | NPC_TYPES: { 46 | [string]: NPC_CONFIG, 47 | DEFAULT: NPC_CONFIG, 48 | }, 49 | 50 | PLAYER_MODELS: { [string]: Model }, 51 | 52 | SHOW_WARNINGS: boolean, 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: write 9 | 10 | env: 11 | GH_TOKEN: ${{ github.token }} 12 | 13 | jobs: 14 | upload-wally: 15 | name: Publish to Wally 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout release tag 20 | uses: actions/checkout@v4 21 | with: 22 | ref: ${{ github.event.release.tag_name }} 23 | fetch-depth: 0 24 | 25 | - name: Setup Rokit 26 | uses: CompeyDev/setup-rokit@v0.1.0 27 | 28 | - name: Log into Wally 29 | run: wally login --token "${{ secrets.WALLY_SECRET }}" 30 | 31 | - name: Publish to Wally 32 | run: wally publish || echo "failed to publish to wally, continuing..." 33 | 34 | upload-build: 35 | name: Build & Attach .rbxm 36 | runs-on: ubuntu-latest 37 | 38 | steps: 39 | - name: Checkout release tag 40 | uses: actions/checkout@v4 41 | with: 42 | ref: ${{ github.event.release.tag_name }} 43 | fetch-depth: 0 44 | 45 | - name: Setup Rokit 46 | uses: CompeyDev/setup-rokit@v0.1.0 47 | 48 | - name: Build rbxm 49 | run: rojo build default.project.json -o build.rbxm 50 | 51 | - name: Upload asset to GitHub Release 52 | run: gh release upload "${{ github.event.release.tag_name }}" build.rbxm --clobber 53 | -------------------------------------------------------------------------------- /src/events.blink: -------------------------------------------------------------------------------- 1 | option ClientOutput = "./Events/Client" 2 | option ServerOutput = "./Events/Server" 3 | option RemoteScope = "CHRONO" 4 | 5 | struct Transform { 6 | Position: vector, 7 | RotationY: f16, 8 | } 9 | 10 | event ClientReplicateCFrame { 11 | from: Client, 12 | type: Unreliable, 13 | call: SingleSync, 14 | data: struct { 15 | timestamp: f32, 16 | cframe: Transform 17 | } 18 | } 19 | 20 | event ServerReplicateCFrame { 21 | from: Server, 22 | type: Unreliable, 23 | call: SingleSync, 24 | data: struct { 25 | timestamps: map {[u16]: f32}, 26 | cframes: map {[u16]: Transform}, 27 | } 28 | } 29 | 30 | event TickRateChanged { 31 | from: Server, 32 | type: Reliable, 33 | call: SingleSync, 34 | data: struct { 35 | id: u16, 36 | tickRate: f32, 37 | } 38 | } 39 | 40 | event TogglePlayerReplication { 41 | from: Server, 42 | type: Reliable, 43 | call: SingleSync, 44 | data: struct { 45 | id: u16, 46 | on: boolean, 47 | } 48 | } 49 | 50 | event InitializePlayer { 51 | from: Server, 52 | type: Reliable, 53 | call: ManyAsync, 54 | data: struct { 55 | id: u16, 56 | player: string, 57 | } 58 | } 59 | 60 | event InitializeExistingPlayers { 61 | from: Server, 62 | type: Reliable, 63 | call: ManyAsync, 64 | data: struct { 65 | id: u16, 66 | player: string, 67 | }[] 68 | } 69 | -------------------------------------------------------------------------------- /docs/API/ChronoClient.md: -------------------------------------------------------------------------------- 1 | # ChronoClient 2 | 3 | ## Public API 4 | 5 | ### `RegisterClientNPC(id: number, model: Model, npcType: string?)` 6 | 7 | Registers a **client-side NPC** for visual interpolation. 8 | 9 | - **`id`** – Network ID assigned by ChronoServer 10 | - **`model`** – NPC model to interpolate 11 | - **`npcType`** *(optional)* – Type key in `Config.NPC_TYPES` 12 | 13 | :::tip 14 | This is automatically called when registered in NpcRegistry on the server. 15 | ::: 16 | --- 17 | 18 | ### `idMap: { [number]: { ... } }` 19 | 20 | Stores the **client-side replication state** for all entities: 21 | 22 | - `snapshot` – Circular snapshot buffer of CFrames 23 | - `character` – Linked `Model` (player or NPC) 24 | - `lastCFrame` – Last rendered CFrame 25 | - `isNPC` – Marks NPCs for fixed buffer logic 26 | - `npcType` – Type used for interpolation config 27 | 28 | --- 29 | 30 | ### `playerTickRates: { [number]: number }` 31 | 32 | Tracks **per-entity tick rates** dynamically received from the server. 33 | 34 | --- 35 | 36 | ### `BufferTracker` 37 | 38 | Reference to the **InterpolationBuffer instance** used to calculate dynamic interpolation delays. 39 | 40 | --- 41 | 42 | ### `GetAllNetworkIds() → { number }` 43 | 44 | Returns a list of all **network IDs** currently tracked on the client. 45 | 46 | 47 | ## Notes 48 | 49 | - **Player replication** is fully automatic after requiring ChronoClient 50 | - **NPCs must be manually registered** with `RegisterClientNPC()` 51 | - **idMap** can be read for debugging or rendering purposes 52 | - Motion is **fully decoupled from Roblox physics buffering** 53 | 54 | -------------------------------------------------------------------------------- /docs/Learn/Disabling-Default-Replication.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Disabling Default Replication 3 | outline: deep 4 | --- 5 | 6 | # Disabling Default Replication 7 | 8 | There are **two common methods** for **disabling default Roblox character replication**, 9 | each with their **own complexities**: 10 | 11 | 1. **Anchoring on server and unanchoring on client** 12 | 2. **Parenting instances to the camera** 13 | 14 | --- 15 | 16 | ## 1. Anchoring on Server & Unanchoring on Client 17 | 18 | This is the **most straightforward** and **simplest** way to disable character replication. 19 | 20 | - Anchor the character **on the server** 21 | - Unanchor the character **on the client** 22 | 23 | ::: tip 24 | Animations **replicate fine** using this method, and **character appearances already replicate** by default. 25 | ::: 26 | 27 | ::: danger 28 | Make sure to anchor the player on the server **after** network ownership has been set by roblox. 29 | ::: 30 | 31 | ### Collision Handling 32 | 33 | - Each player should be **represented as a dummy** 34 | - **Parent the dummy to the camera** (it will **not replicate**) 35 | - Use **`BulkMoveTo`** to move the dummy to the **latest character `CFrame`** for local collision detection 36 | 37 | --- 38 | 39 | ## 2. Parenting to Camera 40 | 41 | Instances **parented to the camera do not replicate**. 42 | 43 | - You will need to **recreate character appearances for all players** on the client 44 | - Best used when you are implementing: 45 | 1. A **custom animation system** 46 | 2. A **custom character controller with collisions** 47 | 48 | ::: info Best Use Case 49 | This method is typically the **go-to** when creating **fully custom characters** with complete **client-side control**. 50 | ::: 51 | -------------------------------------------------------------------------------- /docs/Learn/Client-Tick.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Client Tick 3 | outline: deep 4 | --- 5 | 6 | # Client Tick 7 | 8 | A common thing people do when implementing **custom replication** is to: 9 | 10 | - Send **CFrames to the server** 11 | - Have the **server generate a timestamp** to be forwarded to the client 12 | 13 | ::: warning 14 | This is **bad practice** because it only accounts for **the latency of the receiver**, **not the sender**. 15 | This might seem like a minor issue, but it will cause **visible desync in faster-paced movement**. 16 | ::: 17 | 18 | --- 19 | 20 | ## Why Server-Generated Timestamps Cause Problems 21 | 22 | Picture this scenario: 23 | 24 | - **Client A** sends a CFrame at local time **t = 100ms** 25 | - **Client B** sends theirs at **t = 90ms** 26 | - Due to network latency, **both packets arrive at the server around t = 120ms** 27 | 28 | If the **server assigns the same timestamp** to both packets and forwards them to other clients: 29 | 30 | 1. The **actual send times are ignored** 31 | 2. The **interpolation on receiving clients becomes inaccurate** 32 | 3. **Fast movements** will appear **desynced or jittery** 33 | 34 | --- 35 | 36 | ## The Correct Approach 37 | 38 | Simply **forwarding timestamps from the client to the server** solves this issue. 39 | 40 | - The client **attaches its local timestamp** to the remote event 41 | - The server **uses that timestamp** instead of generating a new one 42 | - Receiving clients can then **calculate latency correctly** for both **sender and receiver** 43 | 44 | --- 45 | 46 | ### Example 47 | 48 | | ❌ Bad | ✅ Good | 49 | |-----------------|-----------------| 50 | | Client fires remote | Client fires remote with timestamp attached | 51 | | Server generates new timestamp | Server uses that timestamp | 52 | | Client uses that timestamp and calculates latency from there | Server forwards to clients; receiving client calculates latency including both sender & receiver | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | function sidebar() { 4 | return { 5 | "/Learn": [ 6 | { text: "Quick Start", collapsed: false, items: [ 7 | { text: "Why", link: "/Learn/Why" }, 8 | { text: "Installation", link: "/Learn/Installation" }, 9 | { text: "Usage", link: "/Learn/Usage" }, 10 | ] }, 11 | { text: "Learn", collapsed: false, items: [ 12 | { text: "Interpolation Buffer", link: "/Learn/Interpolation-Buffer" }, 13 | { text: "Snapshots", link: "/Learn/Snapshots" }, 14 | { text: "Client Tick", link: "/Learn/Client-Tick" }, 15 | { text: "Serialization", link: "/Learn/Serialization" }, 16 | { text: "Disabling Default Replication", link: "/Learn/Disabling-Default-Replication" }, 17 | ] }, 18 | ], 19 | "/API": [ 20 | {text: "API", collapsed: false, items: [ 21 | { text: "ChronoClient", link: "/API/ChronoClient" }, 22 | { text: "ChronoServer", link: "/API/ChronoServer" }, 23 | { text: "Snapshots", link: "/API/Snapshots" }, 24 | { text: "InterpolationBuffer", link: "/API/Interpolation-Buffer" }, 25 | { text: "RenderCache", link: "/API/RenderCache" }, 26 | { text: "NpcRegistry", link: "/API/NpcRegistry" }, 27 | { text: "Config", link: "/API/Config" }, 28 | { text: "Character", link: "/API/Character" }, 29 | ] } 30 | ], 31 | "/Benchmarks": [ 32 | { text: "Benchmarks", collapsed: false, items: [ 33 | { text: "Overview", link: "/Benchmarks/Overview" }, 34 | { text: "Comparison", link: "/Benchmarks/Comparison" }, 35 | ] } 36 | ], 37 | } 38 | } 39 | 40 | export default defineConfig({ 41 | base: '/Chrono/', 42 | title: 'Chrono', 43 | description: 'Chrono', 44 | lang: 'en-US', 45 | head: [ 46 | // ['link', { rel: 'icon', href: '/favicon.png' }], 47 | ], 48 | themeConfig: { 49 | // siteTitle: false, 50 | logo: './logo.png', 51 | outline: 'deep', 52 | socialLinks: [ 53 | { icon: 'github', link: 'https://github.com/Parihsz/Chrono' }, 54 | ], 55 | sidebar: sidebar(), 56 | } 57 | }) 58 | -------------------------------------------------------------------------------- /docs/Learn/Snapshots.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Snapshots 3 | outline: deep 4 | --- 5 | 6 | # Snapshots 7 | 8 | Snapshots sound simple to implement, because one may think it is just **keeping up a table and indexing the time**. 9 | 10 | ::: info 11 | In reality, it is a bit more complicated - but still **very straightforward once you understand it**. 12 | You want to get an **"interpolated snapshot"** at the current `renderTime` rather than just the raw snapshot for that time. 13 | ::: 14 | 15 | --- 16 | 17 | ## Why Interpolation Matters 18 | 19 | Imagine this scenario: 20 | 21 | - You are sending **CFrames at 30Hz**, which is roughly **33.3ms per position update**. 22 | - A player is at **17ms** between updates. 23 | 24 | You now have: 25 | 26 | - Snapshot at **0ms** 27 | - Snapshot at **33ms** 28 | 29 | If you simply snap to either snapshot, **motion will look jittery or disconnected**. 30 | 31 | ::: tip Key Concept 32 | Interpolating between the two snapshots allows you to **estimate the player’s state at 17ms** smoothly. 33 | ::: 34 | 35 | --- 36 | 37 | ## Snapshot Interpolation Methods 38 | 39 | There are **two common methods** for snapshot interpolation: 40 | 41 | 1. **Linear Interpolation** 42 | 2. **Hermite Interpolation** 43 | 44 | --- 45 | 46 | ### 1. Linear Interpolation 47 | 48 | Linear interpolation is the **simplest and most common** method. 49 | 50 | - You **linearly interpolate** between snapshots as you move forward through the array. 51 | - This is the **recommended** approach for most snapshot systems. 52 | 53 | --- 54 | 55 | ### 2. Hermite Interpolation 56 | 57 | Hermite interpolation is the **method Roblox uses**. 58 | 59 | - It is **calculus-based** and **approximates functions** 60 | - Hermite interpolation **constructs a polynomial** that matches both **values and derivatives** at specific points 61 | - It produces **smoother curves**, especially for **easing in and out of motion** 62 | 63 | ::: warning Complexity 64 | Hermite interpolation adds **unnecessary complexity** for most use cases. 65 | It is **not required** for basic snapshot smoothing. 66 | ::: 67 | 68 | --- 69 | 70 | ## Additional Resources 71 | 72 | > **Further Reading:** [Snapshot Interpolation – Gaffer on Games](https://gafferongames.com/post/snapshot_interpolation/) 73 | -------------------------------------------------------------------------------- /docs/API/RenderCache.md: -------------------------------------------------------------------------------- 1 | # RenderCache 2 | 3 | ## Initialization 4 | 5 | ### `RenderCache.Init(dependencies)` 6 | 7 | Initializes the module. 8 | 9 | | Parameter | Type | Description | 10 | |-----------------------|----------------------|------------------------------------------------| 11 | | `playerTickRates` | `{ [number]: number }` | Map of network IDs to tick rates. | 12 | | `bufferTracker` | `InterpolationBuffer` | Reference to an interpolation buffer tracker. | 13 | 14 | --- 15 | 16 | ## Methods 17 | 18 | ### `RenderCache.GetBuffer(id: number) → number` 19 | 20 | Returns the **current interpolation buffer** for the given network ID. 21 | 22 | - NPCs use the **fixed buffer** from `Config.NPC_TYPES` 23 | - Players use the **dynamic buffer** calculated by `InterpolationBuffer` 24 | 25 | --- 26 | 27 | ### `RenderCache.OnSnapshotUpdate(snapshot: { [number]: number })` 28 | 29 | Updates **internal timing** whenever a new snapshot of **server timestamps** is received. 30 | 31 | - Stores the last known server tick for each entity 32 | - Prepares for calculating the **next render timestamp** 33 | 34 | --- 35 | 36 | ### `RenderCache.Update(deltaTime: number)` 37 | 38 | Advances **target render times** per entity. 39 | 40 | - Predicts server time based on last tick and delta 41 | - Smooths timing errors using drift correction 42 | - Snaps to server time if error exceeds **0.1s** 43 | 44 | --- 45 | 46 | ### `RenderCache.GetTargetRenderTime(id: number) → number` 47 | 48 | Returns the **target render timestamp** for a given entity. 49 | If no render time exists yet, returns `0` and emits a warning. 50 | 51 | --- 52 | 53 | ### `RenderCache.GetEstimatedServerTime(id: number) → number` 54 | 55 | Estimates the **current server time** for an entity. 56 | Uses the last known tick plus local elapsed time. 57 | 58 | --- 59 | 60 | ### `RenderCache.Add(id: number, isNPC: boolean?, npcType: string?)` 61 | 62 | Registers a new **player or NPC** in the cache. 63 | 64 | - Initializes timing data 65 | - NPCs are flagged and associated with a type if provided 66 | 67 | --- 68 | 69 | ### `RenderCache.Remove(id: number)` 70 | 71 | Removes an entity from the cache and clears all timing information. 72 | -------------------------------------------------------------------------------- /benchmarks/TestNPC.server.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local RunService = game:GetService("RunService") 3 | 4 | local Models = {} 5 | 6 | local ENABLED = false 7 | 8 | local USE_CHRONO = true 9 | local NUM_MODELS = 20 10 | local MODE: "FOLLOW" | "RANDOM" = "FOLLOW" 11 | local RATE = 0 12 | 13 | if not ENABLED then 14 | return 15 | end 16 | 17 | local Player = game:GetService("Players").PlayerAdded:Wait() 18 | local Character = Player.Character or Player.CharacterAdded:Wait() 19 | Character.Archivable = true 20 | 21 | local Chrono = require(ReplicatedStorage.Packages.chrono) 22 | local NpcRegistry = Chrono.NpcRegistry 23 | local ServerChrono = Chrono.ChronoServer 24 | 25 | print("Starting test...") 26 | 27 | local function createModel() 28 | local model 29 | if MODE == "RANDOM" then 30 | model = Instance.new("Model") 31 | model.Name = "TestNPC_" .. #Models + 1 32 | model.Parent = workspace 33 | local part = Instance.new("Part") 34 | part.Name = "HumanoidRootPart" 35 | part.Size = Vector3.new(2, 5, 2) 36 | part.Anchored = true 37 | part.CanCollide = false 38 | part.Parent = model 39 | model.PrimaryPart = part 40 | else 41 | model = Character:Clone() 42 | model.Parent = workspace 43 | model.Name = "TestNPC_" .. #Models + 1 44 | model.Humanoid.DisplayName = model.Name 45 | end 46 | 47 | if USE_CHRONO then 48 | NpcRegistry.Register(model, "DEFAULT", nil, true) 49 | do 50 | local rule = ServerChrono.GetReplicationRule(model) 51 | rule.filterType = "include" -- This will only replicate to the first player 52 | rule.filterPlayers = { Player } 53 | end 54 | -- task.delay(10, function() 55 | -- NpcRegistry.UnRegister(model) 56 | -- end ) 57 | end 58 | table.insert(Models, model) 59 | end 60 | 61 | for i = 1, NUM_MODELS do 62 | createModel() 63 | end 64 | 65 | local function move() 66 | for i, v in Models do 67 | if MODE == "RANDOM" then 68 | v.PrimaryPart.Anchored = true 69 | v:PivotTo(CFrame.new(math.random(-100, 100), math.random(-100, 100), math.random(-100, 100))) 70 | elseif MODE == "FOLLOW" then 71 | v.Humanoid:MoveTo(Player.Character.HumanoidRootPart.Position) 72 | end 73 | end 74 | end 75 | 76 | task.spawn(function() 77 | while true do 78 | move() 79 | task.wait(RATE) 80 | end 81 | end) 82 | 83 | return nil 84 | -------------------------------------------------------------------------------- /docs/API/Snapshots.md: -------------------------------------------------------------------------------- 1 | # Snapshots 2 | 3 | ## Types 4 | 5 | ### `SnapshotData` 6 | 7 | | Field | Type | Description | 8 | |----------|-------|--------------------------| 9 | | `t` | number | The snapshot timestamp | 10 | | `value` | `T` | The stored value | 11 | 12 | --- 13 | 14 | ### `Snapshot` 15 | 16 | A snapshot buffer with indexing and interpolation. 17 | 18 | | Field / Method | Type | Description | 19 | |---------------------------------|---------------------------------------------------|------------------------------------------------| 20 | | `cache` | `{ SnapshotData }` | Internal buffer | 21 | | `lerp(a: T, b: T, alpha: number)` | `(T, T, number) -> T` | Linear interpolation function for type `T` | 22 | | `Push(t, value)` | `(self, number, T) -> ()` | Inserts a snapshot at timestamp `t` | 23 | | `GetLatest()` | `(self) -> SnapshotData?` | Returns the most recent snapshot | 24 | | `GetAt(t)` | `(self, number) -> T?` | Returns an **interpolated value** at time `t` | 25 | 26 | --- 27 | 28 | ## Methods 29 | 30 | ### `Push(t: number, value: T)` 31 | 32 | Inserts a snapshot into the buffer. 33 | 34 | - Maintains **chronological order** 35 | - Overwrites **oldest entries** when full 36 | 37 | --- 38 | 39 | ### `GetLatest() → SnapshotData?` 40 | 41 | Returns the **most recent snapshot**, or `nil` if empty. 42 | 43 | --- 44 | 45 | ### `GetAt(t: number) → T?` 46 | 47 | Returns the **interpolated value** at timestamp `t`: 48 | 49 | - Interpolates linearly between **before** and **after** snapshots 50 | - Handles **wrap-around** between `0` and `255` timestamps 51 | - Returns the **closest value** if interpolation is not possible 52 | 53 | --- 54 | 55 | ## Usage Notes 56 | 57 | - `lerp` must handle your type `T` (e.g., `CFrame.Lerp`, `Vector3:Lerp`) 58 | - Used internally by **Chrono replication** for **smooth CFrame interpolation** 59 | - Buffer automatically handles **late or out-of-order packets** without breaking motion continuity 60 | -------------------------------------------------------------------------------- /docs/API/Interpolation-Buffer.md: -------------------------------------------------------------------------------- 1 | # InterpolationBuffer 2 | 3 | ## Constructor 4 | 5 | **`InterpolationBuffer.new(minBuffer: number, maxBuffer: number, alpha: number) → InterpolationBuffer`** 6 | 7 | Creates a new interpolation buffer instance. 8 | 9 | | Parameter | Type | Description | 10 | |-----------------|--------|-----------------------------------------------------------------------------| 11 | | `minBuffer` | number | Minimum interpolation buffer in seconds. | 12 | | `maxBuffer` | number | Maximum interpolation buffer in seconds. | 13 | | `alpha` | number | Smoothing factor for the **moving average latency**. | 14 | 15 | **Returns:** 16 | A table representing the InterpolationBuffer instance with the methods below. 17 | 18 | --- 19 | 20 | ## Properties 21 | 22 | ### `PlayerLatencies: { [networkId: number]: { averageLatency: number, deviation: number, lastLatency: number? } }` 23 | 24 | A dictionary storing **per‑player latency data**, keyed by **networkId**. 25 | Each entry tracks: 26 | 27 | - **averageLatency** → smoothed round-trip time (RTT) 28 | - **deviation** → moving deviation (jitter estimate) 29 | - **lastLatency** → most recent latency sample 30 | 31 | --- 32 | 33 | ## Methods 34 | 35 | ### `RegisterPacket(networkId: number, serverTime: number, tickRate: number)` 36 | 37 | Registers an incoming packet for latency tracking. 38 | 39 | | Parameter | Type | Description | 40 | |---------------|--------|-----------------------------------------------------| 41 | | `networkId` | number | Unique ID representing the player or NPC. | 42 | | `serverTime` | number | Timestamp from the **server** for when the packet was sent. | 43 | | `tickRate` | number | Sending tick rate (Hz) for this entity. | 44 | 45 | --- 46 | 47 | ### `GetBuffer(networkId: number, tickRate: number) → number` 48 | 49 | Calculates the **current recommended interpolation buffer** for a given network ID. 50 | 51 | | Parameter | Type | Description | 52 | |---------------|--------|------------------------------------| 53 | | `networkId` | number | Player or NPC network ID. | 54 | | `tickRate` | number | Tick rate (Hz) for this entity. | 55 | 56 | **Returns:** 57 | A **buffer duration in seconds**, clamped between `minBuffer` and `maxBuffer`. 58 | -------------------------------------------------------------------------------- /test/Debugger.luau: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | local Config = require(ReplicatedStorage.Packages.chrono.Shared.Config) 3 | local Iris = require(ReplicatedStorage.DevPackages.iris) 4 | local Replication = require(ReplicatedStorage.Packages.chrono.Client.Replicate) 5 | 6 | local GetAllNetworkIds = Replication.GetAllNetworkIds 7 | local BufferTracker = Replication.BufferTracker 8 | 9 | local playerTickRates = Replication.playerTickRates 10 | local idMap = Replication.idMap 11 | 12 | local searchText = Iris.State("") :: typeof(Iris.State("")) 13 | local networkIds = GetAllNetworkIds() 14 | 15 | Iris.Init() 16 | 17 | Iris:Connect(function() 18 | if Iris.Window({ "Replication Debug" }) then 19 | if Iris.Button({ "🔄 Refresh" }).clicked then 20 | networkIds = GetAllNetworkIds() 21 | end 22 | 23 | Iris.InputText({ "Search ID" }, { text = searchText }) 24 | Iris.Separator() 25 | 26 | for _, id in networkIds do 27 | if not tostring(id):find(searchText:get()) then 28 | continue 29 | end 30 | 31 | if Iris.Tree({ `Player ID: {id}` }).state.isUncollapsed.value then 32 | local tickRate = playerTickRates[id] or Config.TICK_RATE 33 | local bufferTime = BufferTracker.GetBuffer(id, tickRate) 34 | local latencyInfo = BufferTracker.PlayerLatencies[id] 35 | 36 | Iris.Separator() 37 | 38 | if latencyInfo then 39 | local latencyLabel = if latencyInfo.averageLatency > 0.2 then "🔴" else "🟢" 40 | local deviationLabel = if latencyInfo.deviation > 0.05 then "🔴" else "🟢" 41 | 42 | Iris.Text({ `{latencyLabel} Client1 -> Server -> Client2 Latency: {latencyInfo.averageLatency}` }) 43 | Iris.Text({ `{deviationLabel} Deviation: {latencyInfo.deviation}` }) 44 | Iris.Text({ `Buffer Time: {bufferTime}` }) 45 | Iris.Text({ `Tick Rate: {tickRate}` }) 46 | else 47 | Iris.Text({ "No latency/deviation data, please investigate!" }) 48 | end 49 | 50 | Iris.Separator() 51 | Iris.Text({ "📦 Snapshot Cache:" }) 52 | 53 | for i, snapshot in idMap[id].snapshot.cache do 54 | local snapshotText = Iris.State("") 55 | 56 | Iris.Text({ snapshotText:get() }) 57 | 58 | task.defer(function() 59 | local position = if snapshot.value 60 | then `({snapshot.value.Position.X}, {snapshot.value.Position.Y}, {snapshot.value.Position.Z})` 61 | else "❌ nil" 62 | snapshotText:set(`{i}: {snapshot.t} - Position {position}`) 63 | end) 64 | end 65 | end 66 | 67 | Iris.End() 68 | end 69 | 70 | Iris.End() 71 | end 72 | end) 73 | -------------------------------------------------------------------------------- /docs/API/Config.md: -------------------------------------------------------------------------------- 1 | # Config 2 | Configs on how chronos works. This can be modified pre-runtime or at runtime before initialization. 3 | 4 | :::danger 5 | Remember to modify the config on both the server and client. 6 | ::: 7 | 8 | :::danger 9 | Only the properties `NPC_MODELS` and `NPC_TYPES` may be modified after `Chrono.Start()` is called, otherwise an error would occur. Other properties must be modified before running `Chrono.Start()`. 10 | ::: 11 | 12 | ## Types 13 | 14 | ### `NPC_CONFIG` 15 | 16 | 17 | ## Types 18 | 19 | ### `NPC_CONFIG` 20 | 21 | | Field | Type | Description | 22 | |----------|-------|--------------------------| 23 | | `TICK_RATE` | number | The tick rate for the npc | 24 | | `BUFFER` | number | the buffer time for the npc | 25 | 26 | --- 27 | 28 | ## Properties 29 | 30 | ### `MIN_BUFFER: number` 31 | 32 | The minimum buffer time for snapshots. 33 | 34 | --- 35 | 36 | ### `MAX_BUFFER: number` 37 | 38 | The maximum buffer time for snapshots. 39 | 40 | --- 41 | 42 | ### `PROXIMITY: number` 43 | 44 | The proximity threshold in studs where TICK_RATE is applied. 45 | 46 | --- 47 | 48 | ### `ENABLE_CUSTOM_CHARACTERS: boolean` 49 | If true, allows the use of custom characters in the game. This will disable default roblox replication as well. 50 | 51 | --- 52 | 53 | ### `DISABLE_DEFAULT_REPLICATION: boolean` 54 | If true, disables default roblox replication for players. 55 | 56 | :::danger 57 | This will also prevent you from anchoring the primary part of player characters as chrono would be unanchoring locally so they can move as they are anchored on the server to prevent replication. 58 | ::: 59 | 60 | --- 61 | 62 | ### `SEND_FULL_ROTATION: boolean` 63 | If true, clients and server will replicate **full 3D rotation** (as 3× mapped `u16` values). 64 | If false (default), only yaw rotation is sent. 65 | This property can be modified during as chrono is running. 66 | 67 | :::note 68 | Full rotation packets are slightly larger (**24 bytes**) compared to yaw-only packets (**20 bytes**). 69 | ::: 70 | 71 | --- 72 | 73 | ### `SHOW_WARNINGS: boolean` 74 | If true, enables the display of common warnings in the console. 75 | 76 | --- 77 | 78 | ### `NPC_MODELS: { [string]: Model }` 79 | A mapping of NPC model names to models that will be cloned. 80 | 81 | --- 82 | 83 | ### `NPC_TYPES: {[string]: NPC_CONFIG}` 84 | A mapping of NPC types to their configuration. 85 | 86 | --- 87 | 88 | ### `PLAYER_MODELS: { [string]: Model }` 89 | A mapping of player model strings to template models. 90 | Useful for when `ENABLE_CUSTOM_CHARACTERS` is true. 91 | -------------------------------------------------------------------------------- /docs/Learn/Serialization.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Serialization 3 | outline: deep 4 | --- 5 | 6 | # Serialization 7 | 8 | To **optimize for bandwidth**, it is highly recommended to **use buffers to serialize packets**. 9 | 10 | Thankfully, there are networking libraries such as **Bytenet** and **Blink** to handle **SerDes (Serialization/Deserialization)** easily. In **Chrono**, we are using our own SerDes to squeeze out even more performance. 11 | 12 | --- 13 | 14 | ## CFrame Serialization 15 | 16 | CFrames consist of **both positional and rotational components**. 17 | 18 | ### Position 19 | 20 | - Use ``f32`` for position 21 | - While `f16` is smaller, it suffers from **precision limits** 22 | - Many **open-source replication systems** uses float16 for position, but those are typically just for **showcase purposes** 23 | 24 | ::: tip 25 | Stick with **float32** for positions if you care about accuracy in a production environment. 26 | ::: 27 | 28 | --- 29 | 30 | ### Rotation 31 | 32 | - Best approach: **convert rotations to quaternions** and **serialize as ``f16``** 33 | - Converting between **CFrame → Quaternion (AxisAngles)** and **Quaternion → CFrame** is straightforward 34 | - Alternatively, you can **just send Y-axis rotation** for many **battleground games**, as it is **precise enough** 35 | 36 | --- 37 | 38 | ## Timestamp Handling 39 | 40 | ```lua 41 | GetServerTimeNow() 42 | ``` 43 | 44 | - Provides a **synchronized clock between client and server** 45 | - **Avoids the need** to manually compensate for **clock drift** due to latency variations 46 | 47 | ::: danger RenderCache 48 | From my tests, GetServerTimeNow has issues making interpolation jittery. Chrono uses os.clock and predicting the estimated server time and renderTimeError; this system also provides much smoother control over visuals. 49 | ::: 50 | --- 51 | 52 | ## Rendering Timestamps 53 | 54 | When rendering timestamps: 55 | 56 | 1. Compute **remote latency** easily by comparing a synced clock time on the server and client 57 | 2. Subtract your **interpolation buffer** to compute the **correct render time** 58 | 59 | --- 60 | 61 | ## Performance & Bandwidth Tips 62 | 63 | ::: warning Float Cost 64 | `GetServerTimeNow()` returns a **`f64`**, which is **more costly** than `os.clock()` (**`f32`**) 65 | ::: 66 | 67 | ### Bandwidth Optimization 68 | 69 | - **Encode timestamps** to reduce bandwidth 70 | 71 | ```lua 72 | -- Example: compress to f16 range 73 | timestamp = GetServerTimeNow() % 255 74 | ``` 75 | 76 | ::: danger Time Wrapping 77 | If you implement time wrapping like this: 78 | Always account for circularity when comparing timestamps. Failing to do so will result in incorrect snapshot ordering 79 | ::: 80 | -------------------------------------------------------------------------------- /docs/Learn/Usage.md: -------------------------------------------------------------------------------- 1 | # Basic Usage 2 | 3 | Chrono runs **on top of Roblox’s default replication by default** and can optionally take full control if you disable replication. This makes it **easy to drop in** without breaking your existing character setup. 4 | 5 | 6 | To implement: 7 | 8 | - Drop the client and server modules into **ReplicatedStorage** (with the suggested structure) 9 | - Require them in your **server and client scripts** 10 | - Most replication (snapshots, interpolation buffers, networkables) works **automatically** 11 | 12 | --- 13 | 14 | ### Initial Setup 15 | 16 | ```lua 17 | --Client 18 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 19 | require(ReplicatedStorage.Packages.chrono).Start() 20 | ``` 21 | ```lua 22 | --Server 23 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 24 | require(ReplicatedStorage.Packages.chrono).Start() 25 | ``` 26 | Once required and started: 27 | 28 | - **Player replication** will begin 29 | - **Snapshots** and **dynamic interpolation buffers** are handled internally 30 | - **CFrame updates** are batched and serialized 31 | 32 | --- 33 | 34 | ### Registering NPCs 35 | 36 | If you want to **replicate NPCs** with Chrono: 37 | ```lua 38 | --Server-side 39 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 40 | local NpcRegistry = require(ReplicatedStorage.Packages.chrono).NpcRegistry 41 | 42 | NpcRegistry.Register(npcModel, "DEFAULT", "TestNPCs", true) 43 | 44 | --you will simply need to update the npc's cframe and it would automatically be replicated. 45 | --default replication is fully disabled in this case. 46 | --animations will not replicate from server. please play them from the client. 47 | ``` 48 | 49 | #### Notes 50 | 51 | - You must use ChronoServer/ChronoClient's npc registration function if you want to perform headless tracking without a model. NpcRegistry requires a model for it to work. 52 | - `PushNPCTransform` **updates the NPC’s snapshot**, which Chrono automatically interpolates on all clients. NpcRegistry will automatically call this if you change the CFrame of a npc registered within it on the server. 53 | - NPCs are treated the same as players except they will have a fixed interpolation buffer 54 | 55 | :::danger 56 | Animations are non replicated from server to client. If you want NPC animations to be visible, you must play them on the clients. This is also idiomatic as it optimizes roblox transforms. 57 | ::: 58 | --- 59 | 60 | ### Getting the Latest CFrame 61 | 62 | If you need the **latest replicated position** of a player or NPC for logic (hit detection, AI, etc.): 63 | ```lua 64 | local ChronoServer = require(ReplicatedStorage.Packages.chrono).ChronoServer 65 | local npcId = ChronoServer.GetId(npcModel_OR_player) -- or npcModel:GetAttribute("NPC_ID") 66 | local cframe = ChronoServer.GetLatestCFrame(npcId or player) 67 | if cframe then 68 | print(`Latest replicated position: {cframe.Position}`) 69 | end 70 | ``` 71 | --- 72 | -------------------------------------------------------------------------------- /src/Client/InterpolationBuffer.luau: -------------------------------------------------------------------------------- 1 | local RenderCache = require(script.Parent.RenderCache) 2 | local Config = require(script.Parent.Parent.Shared.Config) 3 | 4 | return function(minBuffer: number, maxBuffer: number, alpha: number) 5 | --average latency will be the average RTT between the client & server 6 | local playerLatencies = {} :: { [number]: { averageLatency: number, deviation: number, lastLatency: number? } } 7 | local fix = 0.2 8 | local recovery = 0.5 9 | 10 | local function RegisterPacket(networkId: number, serverTime: number, tickRate: number) 11 | local clientNow = RenderCache.GetEstimatedServerTime(networkId) 12 | local latency = clientNow - serverTime 13 | if latency > 1 then 14 | playerLatencies[networkId] = nil 15 | RenderCache.Remove(networkId) 16 | RenderCache.Add(networkId) 17 | 18 | if Config.SHOW_WARNINGS then 19 | warn(`{networkId} latency too high, cleared cache to repredict in case of error:! {latency}`) 20 | end 21 | end 22 | if not playerLatencies[networkId] then 23 | --using the difference between current and last latency, i could smooth out the deviation (the variation in latency, which correlates to packet loss) 24 | --this is using statistics https://en.wikipedia.org/wiki/Moving_average 25 | playerLatencies[networkId] = { averageLatency = latency, deviation = 0, lastLatency = latency } 26 | return 27 | end 28 | 29 | local data = playerLatencies[networkId] 30 | if data.lastLatency then 31 | local delta = math.abs(latency - data.lastLatency) 32 | data.deviation = data.deviation * (1 - fix) + delta * fix 33 | end 34 | 35 | data.averageLatency = data.averageLatency * (1 - alpha) + latency * alpha 36 | data.lastLatency = latency 37 | end 38 | 39 | local function GetBuffer(networkId: number, tickRate: number) 40 | --calculate the interpolation buffer that accounts for expected latency, possible deviations and recovery from delays 41 | --the buffer will be per player 42 | local data = playerLatencies[networkId] 43 | if not data then 44 | return minBuffer 45 | end 46 | 47 | local recoveryMargin = tickRate * recovery 48 | local rawBuffer = data.averageLatency + data.deviation + recoveryMargin 49 | 50 | local buffer = if rawBuffer < minBuffer then minBuffer + (minBuffer - rawBuffer) * 0.2 else rawBuffer 51 | if buffer > maxBuffer then 52 | if Config.SHOW_WARNINGS then 53 | warn(`Interpolation buffer exceeded max! Was {buffer}, clamped to {maxBuffer}`) 54 | end 55 | buffer = maxBuffer 56 | end 57 | 58 | return buffer 59 | end 60 | 61 | local function Remove(id: number) 62 | playerLatencies[id] = nil 63 | RenderCache.Remove(id) 64 | end 65 | 66 | local function Clear(id) 67 | playerLatencies[id] = nil 68 | RenderCache.Remove(id) 69 | RenderCache.Add(id) 70 | end 71 | 72 | return { 73 | RegisterPacket = RegisterPacket, 74 | GetBuffer = GetBuffer, 75 | Remove = Remove, 76 | PlayerLatencies = playerLatencies, 77 | Clear = Clear, 78 | } 79 | end 80 | -------------------------------------------------------------------------------- /src/Shared/MakeNetworkable.luau: -------------------------------------------------------------------------------- 1 | type NetworkableCFrame = { 2 | Position: Vector3, 3 | Rotation: { x: number, y: number, z: number }, 4 | } 5 | type NetworkableYawCFrame = { 6 | Position: Vector3, 7 | RotationY: number, 8 | } 9 | 10 | local SERVER_TIME = workspace:GetServerTimeNow() 11 | local RunService = game:GetService("RunService") 12 | 13 | if RunService:IsClient() then 14 | script:GetAttributeChangedSignal("ServerTime"):Connect(function() 15 | SERVER_TIME = script:GetAttribute("ServerTime") or workspace:GetServerTimeNow() 16 | end) 17 | SERVER_TIME = script:GetAttribute("ServerTime") or SERVER_TIME 18 | else 19 | script:SetAttribute("ServerTime", SERVER_TIME) 20 | end 21 | 22 | local function MakeNetworkable(cframe: CFrame): NetworkableCFrame 23 | local position = cframe.Position 24 | --AxisAngles very simple way to think about quaternions 25 | --can convert into quaternions by multiplying sine of the half angle 26 | local axis, angle = cframe:ToAxisAngle() 27 | 28 | local halfAngle = angle * 0.5 29 | local sinHalf = math.sin(halfAngle) 30 | 31 | local x = axis.X * sinHalf 32 | local y = axis.Y * sinHalf 33 | local z = axis.Z * sinHalf 34 | 35 | return { 36 | Position = position, 37 | Rotation = { x = x, y = y, z = z }, 38 | } 39 | end 40 | 41 | local function MakeYawNetworkable(cframe: CFrame): NetworkableYawCFrame 42 | local _, yaw, _ = cframe:ToEulerAnglesYXZ() 43 | return { 44 | Position = cframe.Position, 45 | RotationY = yaw, 46 | } 47 | end 48 | 49 | local function NetworkableCFrameTable(cframes: { CFrame }): { NetworkableCFrame } 50 | local results = {} 51 | for index, cframe in cframes do 52 | results[index] = MakeNetworkable(cframe) 53 | end 54 | return results 55 | end 56 | 57 | local function NetworkableYawCFrameTable(cframes: { [number]: CFrame }): { [number]: NetworkableYawCFrame } 58 | local results = {} 59 | for index, cframe in cframes do 60 | results[index] = MakeYawNetworkable(cframe) 61 | end 62 | return results 63 | end 64 | 65 | local function DecodeCFrame(data: NetworkableCFrame): CFrame 66 | local position = data.Position 67 | local rotation = data.Rotation 68 | 69 | local x, y, z = rotation.x, rotation.y, rotation.z 70 | local wSquared = 1 - x * x - y * y - z * z 71 | local w = if wSquared > 0 then math.sqrt(wSquared) else 0 72 | 73 | return CFrame.new(position.X, position.Y, position.Z, x, y, z, w) 74 | end 75 | 76 | local function DecodeYawCFrame(data: NetworkableYawCFrame): CFrame 77 | local position = data.Position 78 | local yaw = data.RotationY 79 | local c = math.cos(yaw) 80 | local s = math.sin(yaw) 81 | 82 | return CFrame.fromMatrix(position, Vector3.new(c, 0, -s), Vector3.new(0, 1, 0)) 83 | end 84 | 85 | local function EncodeTime(time: number): number 86 | -- return time - SERVER_TIME 87 | return time 88 | --return time 89 | end 90 | 91 | return { 92 | MakeNetworkable = MakeNetworkable, 93 | NetworkableCFrameTable = NetworkableCFrameTable, 94 | DecodeCFrame = DecodeCFrame, 95 | 96 | NetworkableYawCFrameTable = NetworkableYawCFrameTable, 97 | MakeYawNetworkable = MakeYawNetworkable, 98 | DecodeYawCFrame = DecodeYawCFrame, 99 | 100 | EncodeTime = EncodeTime, 101 | } 102 | -------------------------------------------------------------------------------- /src/init.luau: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | local NpcRegistry = require(script.Shared.NpcRegistry) 4 | local ServerReplicate 5 | local ClientReplicate 6 | local InterpolationBuffer 7 | local RenderCache 8 | local TogglePlayerReplication 9 | local Character 10 | 11 | local Snapshots = require(script.Shared.Snapshots) 12 | export type SnapshotData = Snapshots.SnapshotData 13 | export type Snapshot = Snapshots.Snapshot 14 | 15 | local function LoadFolder(folder: Folder) 16 | for _, moduleScript in folder:GetDescendants() do 17 | if moduleScript:IsA("ModuleScript") then 18 | local yielded = true 19 | local success, message 20 | 21 | task.spawn(function() 22 | success, message = pcall(require, moduleScript) 23 | 24 | yielded = false 25 | end) 26 | 27 | if not success then 28 | error(`{moduleScript:GetFullName()}: {message}`) 29 | end 30 | 31 | if yielded then 32 | warn("Yielded while requiring" .. moduleScript:GetFullName()) 33 | end 34 | end 35 | end 36 | end 37 | 38 | local Modules = { 39 | ChronoClient = true, 40 | ChronoServer = true, 41 | NpcRegistry = true, 42 | Snapshots = true, 43 | InterpolationBuffer = true, 44 | RenderCache = true, 45 | TogglePlayerReplication = true, 46 | Character = true, 47 | } 48 | 49 | local Chrono = {} 50 | 51 | local started = false 52 | function Chrono.Start() 53 | if started then 54 | return 55 | end 56 | started = true 57 | 58 | LoadFolder(if RunService:IsServer() then script.Server else script.Client) 59 | LoadFolder(script.Shared) 60 | 61 | if RunService:IsServer() then 62 | ServerReplicate = require(script.Server.Replicate) 63 | TogglePlayerReplication = ServerReplicate.TogglePlayerReplication 64 | else 65 | ClientReplicate = require(script.Client.Replicate) 66 | InterpolationBuffer = require(script.Client.InterpolationBuffer) 67 | RenderCache = require(script.Client.RenderCache) 68 | end 69 | Character = require(script.Shared.Character) 70 | 71 | Chrono.Character = Character 72 | Chrono.ChronoClient = ClientReplicate 73 | Chrono.ChronoServer = ServerReplicate 74 | Chrono.NpcRegistry = NpcRegistry 75 | Chrono.Snapshots = require(script.Shared.Snapshots) 76 | Chrono.InterpolationBuffer = InterpolationBuffer 77 | Chrono.RenderCache = RenderCache 78 | Chrono.TogglePlayerReplication = TogglePlayerReplication 79 | setmetatable(Chrono :: any, nil) 80 | local config = require(script.Shared.Config) 81 | if config.DISABLE_DEFAULT_REPLICATION and config.ENABLE_CUSTOM_CHARACTERS then 82 | warn( 83 | "DISABLE_DEFAULT_REPLICATION and ENABLE_CUSTOM_CHARACTERS are both enabled. Disabling DISABLE_DEFAULT_REPLICATION since its not needed." 84 | ) 85 | config.DISABLE_DEFAULT_REPLICATION = false 86 | end 87 | Chrono.Config = setmetatable({}, { 88 | __index = function(_, key) 89 | return config[key] 90 | end, 91 | __newindex = function(_, key, value) 92 | error(`Attempt to modify Config.{key} after Chrono.Start()`, 2) 93 | end, 94 | }) :: any 95 | end 96 | 97 | Chrono.Config = require(script.Shared.Config) 98 | return ( 99 | setmetatable(Chrono, { 100 | __index = function(_, key) 101 | if Modules[key] then 102 | error(`Attempt to access Chrono.{key} before calling Chrono.Start()`, 2) 103 | end 104 | end, 105 | }) :: any 106 | ) :: typeof(Chrono) 107 | -------------------------------------------------------------------------------- /src/Shared/Character.luau: -------------------------------------------------------------------------------- 1 | local HttpService = game:GetService("HttpService") 2 | local Players = game:GetService("Players") 3 | local RunService = game:GetService("RunService") 4 | local Config = require(script.Parent.Config) 5 | local Signal = require(script.Parent.Signal) 6 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 7 | 8 | local IS_SERVER = RunService:IsServer() 9 | local IS_CLIENT = RunService:IsClient() 10 | 11 | local CharacterAdded: Signal.Signal = Signal.new() 12 | local CharacterRemoved: Signal.Signal = Signal.new() 13 | 14 | local CharacterStoredLocation = Instance.new("Camera", workspace) 15 | CharacterStoredLocation.Name = "PlayerCharacterStorage" 16 | 17 | local ModelCache 18 | 19 | if IS_SERVER then 20 | ModelCache = Instance.new("Folder", ReplicatedStorage) 21 | ModelCache.Name = "PLAYER_MODEL_CACHE" 22 | elseif IS_CLIENT then 23 | ModelCache = ReplicatedStorage:WaitForChild("PLAYER_MODEL_CACHE") :: any 24 | end 25 | 26 | local Cache = Config.PLAYER_MODELS 27 | local Characters = {} 28 | 29 | local Models = {} 30 | 31 | function Characters.GetCharacter(player: Player): Model? 32 | return Models[player] 33 | end 34 | 35 | function Characters.SetCharacter(player: Player, model: Model | string, data: any?) 36 | if Models[player] == model then 37 | return 38 | end 39 | 40 | local model_: Model 41 | if typeof(model) == "string" then 42 | local cachedModel = Cache[model] 43 | if not cachedModel then 44 | error(`No model found for character: {model}, player: {player.Name}`) 45 | end 46 | model_ = cachedModel:Clone() 47 | else 48 | model_ = model 49 | end 50 | 51 | local id = player.UserId 52 | model_.Parent = CharacterStoredLocation 53 | 54 | if not model_.PrimaryPart then 55 | warn("Model does not have a PrimaryPart set:", model) 56 | end 57 | 58 | if Models[player] then 59 | CharacterRemoved:Fire(player, Models[player]) 60 | pcall(workspace.Destroy, Models[player]) 61 | end 62 | 63 | if IS_CLIENT then 64 | Models[player] = model_ 65 | else 66 | Models[player] = model_ 67 | model_.Archivable = true 68 | local clone = model_:Clone() 69 | clone.Name = tostring(id) 70 | clone.Parent = ModelCache 71 | if data then 72 | clone:SetAttribute("_chronoInitData", HttpService:JSONEncode({ data })) 73 | end 74 | end 75 | 76 | CharacterAdded:Fire(player, model_, data) 77 | end 78 | 79 | if IS_CLIENT then 80 | local function handleChild(child: Instance) 81 | local id = tonumber(child.Name) 82 | local data = child:GetAttribute("_chronoInitData") 83 | data = data and HttpService:JSONDecode(data)[1] 84 | child:SetAttribute("_chronoInitData", nil) 85 | 86 | if not id then 87 | child:Destroy() 88 | return 89 | end 90 | 91 | local player = Players:GetPlayerByUserId(id) 92 | if not player then 93 | child:Destroy() 94 | return 95 | end 96 | 97 | if child:IsA("StringValue") then 98 | child = child.Value :: any 99 | child:Destroy() 100 | end 101 | 102 | Characters.SetCharacter(player, child :: any, data) 103 | end 104 | 105 | ModelCache.ChildAdded:Connect(function(child) 106 | task.defer(handleChild, child) 107 | end) 108 | 109 | for _, child in ModelCache:GetChildren() do 110 | task.defer(handleChild, child) 111 | end 112 | end 113 | 114 | Players.PlayerRemoving:Connect(function(player) 115 | local model = Models[player] 116 | if model then 117 | CharacterRemoved:Fire(player, model) 118 | Models[player] = nil 119 | pcall(workspace.Destroy, model) 120 | end 121 | end) 122 | 123 | Characters.CharacterAdded = CharacterAdded.Event 124 | Characters.CharacterRemoved = CharacterRemoved.Event 125 | 126 | return Characters 127 | -------------------------------------------------------------------------------- /src/Client/RenderCache.luau: -------------------------------------------------------------------------------- 1 | local Config = require(script.Parent.Parent.Shared.Config) 2 | 3 | local playerTickRates 4 | local BufferTracker 5 | 6 | local clientClockInfo = {} :: { 7 | [number]: { 8 | lastClockAt: number, 9 | lastClockDuration: number, 10 | renderAt: number?, 11 | }, 12 | } 13 | 14 | local isNPCMap = {} :: { [number]: boolean } 15 | local npcTypeMap = {} :: { [number]: string } 16 | 17 | local function Init(dependencies: { 18 | playerTickRates: { [number]: number }, 19 | bufferTracker: any, 20 | }) 21 | playerTickRates = dependencies.playerTickRates 22 | BufferTracker = dependencies.bufferTracker 23 | end 24 | 25 | local function GetBuffer(id: number): number 26 | if isNPCMap[id] then 27 | local npcType = npcTypeMap[id] or "DEFAULT" 28 | local npcConfig = Config.NPC_TYPES[npcType] 29 | if not npcConfig then 30 | warn(`RenderCache: No NPC config found for type {npcType}. Make sure to define it in the config`) 31 | npcConfig = Config.NPC_TYPES["DEFAULT"] 32 | end 33 | return npcConfig.BUFFER 34 | else 35 | return BufferTracker.GetBuffer(id, playerTickRates[id] or Config.TICK_RATE) 36 | end 37 | end 38 | 39 | local function OnSnapshotUpdate(snapshot: { [number]: number }) 40 | local now = os.clock() 41 | 42 | for id, currentSendTime in snapshot do 43 | local info = clientClockInfo[id] 44 | 45 | if not info then 46 | info = { 47 | lastClockAt = currentSendTime, 48 | lastClockDuration = now, 49 | renderAt = nil, 50 | } 51 | clientClockInfo[id] = info 52 | end 53 | 54 | if currentSendTime > info.lastClockAt then 55 | info.lastClockAt = currentSendTime 56 | info.lastClockDuration = now 57 | 58 | if not info.renderAt then 59 | local delay = GetBuffer(id) 60 | info.renderAt = currentSendTime - delay 61 | end 62 | end 63 | end 64 | end 65 | 66 | local function Update(deltaTime: number) 67 | local now = os.clock() 68 | 69 | for id, info in clientClockInfo do 70 | local delay = GetBuffer(id) 71 | 72 | --predict the current server time based on the last known tick and time difference 73 | --advance the render at by the delta time and calculate error from the dynamic delay 74 | --snap if the error is too big, fall slightly behind to correct overshoot and speed up to catch uip 75 | 76 | local estimatedServerTime = info.lastClockAt + (now - info.lastClockDuration) 77 | local renderAt = (info.renderAt or (estimatedServerTime - delay)) + deltaTime 78 | 79 | local renderTimeError = delay - (estimatedServerTime - renderAt) 80 | 81 | if math.abs(renderTimeError) > 0.1 then 82 | renderAt = estimatedServerTime - delay 83 | elseif renderTimeError > 0.01 then 84 | renderAt = math.max(estimatedServerTime - delay, renderAt - 0.1 * deltaTime) 85 | elseif renderTimeError < -0.01 then 86 | renderAt = math.min(estimatedServerTime - delay, renderAt + 0.1 * deltaTime) 87 | end 88 | 89 | info.renderAt = renderAt 90 | end 91 | end 92 | 93 | local function GetTargetRenderTime(id: number): number 94 | local info = clientClockInfo[id] 95 | if not info or not info.renderAt then 96 | warn(`RenderCache: No render time for network ID {id}`) 97 | return 0 98 | end 99 | return info.renderAt 100 | end 101 | 102 | local function GetEstimatedServerTime(id: number): number 103 | local info = clientClockInfo[id] 104 | if not info then 105 | warn(`RenderCache: No estimated server time for network ID {id}`) 106 | return 0 107 | end 108 | return info.lastClockAt + (os.clock() - info.lastClockDuration) 109 | end 110 | 111 | local function Add(id: number, isNPC: boolean?, npcType: string?) 112 | if not clientClockInfo[id] then 113 | clientClockInfo[id] = { 114 | lastClockAt = 0, 115 | lastClockDuration = 0, 116 | renderAt = nil, 117 | } 118 | end 119 | isNPCMap[id] = isNPC or false 120 | if isNPC then 121 | npcTypeMap[id] = npcType or "DEFAULT" 122 | end 123 | end 124 | 125 | local function Remove(id: number) 126 | clientClockInfo[id] = nil 127 | isNPCMap[id] = nil 128 | npcTypeMap[id] = nil 129 | end 130 | 131 | return { 132 | Init = Init, 133 | GetBuffer = GetBuffer, 134 | OnSnapshotUpdate = OnSnapshotUpdate, 135 | Update = Update, 136 | GetTargetRenderTime = GetTargetRenderTime, 137 | GetEstimatedServerTime = GetEstimatedServerTime, 138 | Add = Add, 139 | Remove = Remove, 140 | } 141 | -------------------------------------------------------------------------------- /src/Shared/Grid.luau: -------------------------------------------------------------------------------- 1 | type Entity = Model 2 | type Entities = { [Entity]: string } 3 | type GridCell = { [string]: { Entity } } 4 | type Grid = { [vector]: GridCell } 5 | 6 | local Grid = {} 7 | 8 | local GRID_SIZE = 50 9 | local UPDATE_INTERVAL = 2 10 | 11 | local lastPositions: { [Entity]: vector } = {} 12 | local lastUpdate = 0 13 | 14 | local grid: Grid = {} 15 | local entities: Entities = {} 16 | 17 | local function HashPosition(position: vector): vector 18 | return vector.create(position.x // GRID_SIZE + 0.5, 0, position.z // GRID_SIZE + 0.5) 19 | end 20 | 21 | local function DotMagnitude(position: vector): number 22 | return vector.dot(position, position) 23 | end 24 | 25 | local function RemoveOld(entity: Entity, entityType: string, lastHash: vector) 26 | local oldCell = grid[lastHash] 27 | if not oldCell then 28 | return 29 | end 30 | 31 | local index = table.find(oldCell[entityType], entity) 32 | if not index then 33 | return 34 | end 35 | 36 | table.remove(oldCell[entityType], index) 37 | if #oldCell[entityType] == 0 then 38 | oldCell[entityType] = nil 39 | end 40 | if next(oldCell) == nil then 41 | grid[lastHash] = nil 42 | end 43 | end 44 | 45 | local function AddNew(entity: Entity, entityType: string, hash: vector) 46 | local cell: GridCell = grid[hash] or {} 47 | cell[entityType] = cell[entityType] or {} 48 | grid[hash] = cell 49 | 50 | table.insert(cell[entityType], entity) 51 | lastPositions[entity] = hash 52 | end 53 | 54 | function Grid.UpdateGrid() 55 | local currentTime = os.clock() 56 | if currentTime - lastUpdate < UPDATE_INTERVAL then 57 | return 58 | end 59 | 60 | for entity, entityType in entities do 61 | local position = entity:GetPivot().Position :: any 62 | local hash = HashPosition(position) 63 | local lastHash = lastPositions[entity] 64 | 65 | if lastHash ~= hash then 66 | if lastHash then 67 | RemoveOld(entity, entityType, lastHash) 68 | end 69 | 70 | AddNew(entity, entityType, hash) 71 | end 72 | end 73 | 74 | lastUpdate = currentTime 75 | end 76 | 77 | local function ProcessEntities( 78 | entitiesTable: { Entity }, 79 | position: vector, 80 | rangeSquared: number, 81 | nearbyEntities: { Entity } 82 | ) 83 | for _, entity in entitiesTable do 84 | local entityPosition = entity:GetPivot().Position :: any 85 | if DotMagnitude(entityPosition - position) <= rangeSquared then 86 | table.insert(nearbyEntities, entity) 87 | end 88 | end 89 | end 90 | 91 | local function ProcessCell( 92 | cell: GridCell, 93 | entityTypes: { string }?, 94 | position: vector, 95 | rangeSquared: number, 96 | nearbyEntities: { Entity } 97 | ) 98 | if entityTypes then 99 | for _, entityType in entityTypes do 100 | local entitiesTable = cell[entityType] 101 | if entitiesTable then 102 | ProcessEntities(entitiesTable, position, rangeSquared, nearbyEntities) 103 | end 104 | end 105 | else 106 | for _, entitiesTable in cell do 107 | ProcessEntities(entitiesTable, position, rangeSquared, nearbyEntities) 108 | end 109 | end 110 | end 111 | 112 | function Grid.QueryGrid(position: vector, range: number, entityTypes: { string }?): { Entity } 113 | Grid.UpdateGrid() 114 | 115 | local rangeInCells = math.ceil(range / GRID_SIZE) 116 | local hash = HashPosition(position) 117 | local startX, startZ = hash.x - rangeInCells, hash.z - rangeInCells 118 | local endX, endZ = hash.x + rangeInCells, hash.z + rangeInCells 119 | 120 | local nearbyEntities: { Entity } = {} 121 | local rangeSquared = range * range 122 | 123 | for xCell = startX, endX do 124 | for zCell = startZ, endZ do 125 | local cellKey = vector.create(xCell, 0, zCell) 126 | local cell = grid[cellKey] 127 | if cell then 128 | ProcessCell(cell, entityTypes, position, rangeSquared, nearbyEntities) 129 | end 130 | end 131 | end 132 | 133 | return nearbyEntities 134 | end 135 | 136 | function Grid.AddEntity(entity: Model, entityType: string) 137 | entities[entity] = entityType 138 | end 139 | 140 | function Grid.RemoveEntity(entity: Model) 141 | if not entity then 142 | return 143 | end 144 | if lastPositions[entity] then 145 | RemoveOld(entity, entities[entity], lastPositions[entity]) 146 | end 147 | entities[entity] = nil 148 | lastPositions[entity] = nil 149 | end 150 | 151 | function Grid.GetNearbyEntities(model: Model?, range: number, entityTypes: { string }?): { any } 152 | if not model then 153 | return {} 154 | end 155 | local position = model:GetPivot().Position :: any 156 | return Grid.QueryGrid(position, range, entityTypes) 157 | end 158 | 159 | return Grid 160 | -------------------------------------------------------------------------------- /docs/API/ChronoServer.md: -------------------------------------------------------------------------------- 1 | # ChronoServer 2 | 3 | 4 | ## Types 5 | 6 | ### `ReplicationRule` 7 | 8 | | Field | Type | Description | 9 | |----------|-------|--------------------------| 10 | | `filterType` | "include"/"exclude" | The filter type for the replication rule. | 11 | | `filterPlayers` | `{Player}` | the players to include/exclude from the replication rule. | 12 | 13 | If no rule is found then every Player will receive updates. Otherwise, only the specified players will receive updates. 14 | 15 | :::danger 16 | When players leave, players stored in `filterPlayers` will continue to exist, make sure to clean them up if necessary. 17 | ::: 18 | 19 | --- 20 | 21 | ## Public API 22 | 23 | ### `idMap: { [number]: { ... } }` 24 | 25 | Stores the **server-side replication state** for all entities: 26 | - `player` - The player instance 27 | - `snapshot` – Circular snapshot buffer of CFrames 28 | - `clientLastTick` The last timestamp of the client 29 | - `serverOwned` – Whether or not the entity is server-owned (player vs NPC) 30 | - `npcType?` - Type of NPC, if any 31 | - `model` – Linked model to the id 32 | - `replicationRule` – The replication filter the entity is under 33 | 34 | --- 35 | 36 | ### `RegisterNPC(model: Model?, npcType: string?) → number` 37 | 38 | Registers a new **NPC** on the server and returns its **network ID**. 39 | 40 | - **`model`** *(optional)* – The NPC’s physical `Model`. 41 | If omitted, Chrono still tracks the NPC **headlessly**. 42 | - **`npcType`** *(optional)* – Type key in `Config.NPC_TYPES`. 43 | Defaults to `"DEFAULT"`. 44 | 45 | :::danger 46 | Use NpcRegistry for physical npcs, or you will need to manually push transforms for movement to be replicated 47 | ::: 48 | --- 49 | 50 | ### `GetReplicationRule(input: Player | Model | number) → ReplicationRule` 51 | 52 | Returns the **replication rule** for a given id, player or model. If no valid id given then return a blank replication rule. 53 | 54 | --- 55 | 56 | ### `SetReplicationRule(input: Player | Model | number, rule: ReplicationRule?)` 57 | 58 | Sets the **replication rule** for a given id, player or model. If no rule given then remove the existing rule. 59 | 60 | --- 61 | 62 | ### `PushNPCTransform(target: number|Model, cframe: CFrame)` 63 | 64 | Pushes a new **CFrame snapshot** for a server-owned NPC. 65 | 66 | --- 67 | 68 | ### `GetLatestCFrame(target: Player | Model | number) → CFrame?` 69 | 70 | Returns the **latest replicated CFrame** for a: 71 | 72 | - **Player** – Pass the `Player` instance 73 | - **NPC** – Pass the `Model` instance 74 | - **Network ID** – Pass the network ID (`number`) 75 | 76 | --- 77 | 78 | ### `GetLatestTime(target: Player | Model | number) → number?` 79 | 80 | Returns the **latest replicated time** for a: 81 | 82 | - **Player** – Pass the `Player` instance 83 | - **NPC** – Pass the `Model` instance 84 | - **Network ID** – Pass the network ID (`number`) 85 | 86 | --- 87 | 88 | ### `GetId(target: Player | Model | number) → number?` 89 | 90 | Returns the **network ID** for a: 91 | 92 | - **Player** – Pass the `Player` instance 93 | - **NPC** – Pass the `Model` instance 94 | - **Network ID** – Pass the network ID (`number`) 95 | 96 | --- 97 | 98 | ### `TogglePlayerReplication(player: Player, on: boolean)` 99 | 100 | Toggles whether Chrono should interpolate the player 101 | 102 | - **player** – Pass the `Player` instance 103 | - **on** – Pass a boolean of whether chrono should interpolate 104 | 105 | --- 106 | 107 | ### `GetAllNetworkIds() → { number }` 108 | 109 | Returns a list of all **network IDs** currently tracked on the server. 110 | 111 | --- 112 | 113 | 114 | ### `Replicators` 115 | 116 | A table mapping **network IDs → dummy Models** used when 117 | `Config.DISABLE_DEFAULT_REPLICATION = true`. 118 | 119 | - Replicators are **visual proxies** cloned from `StarterPlayer.Replicator` 120 | and parented to `workspace.CurrentCamera`. 121 | - Chrono updates them each frame with `BulkMoveTo` based on snapshots. 122 | - They are useful for running queries on the player's characters on the server. 123 | - You can also access a player's replicator via workspace.Camera[player.Name]. 124 | 125 | --- 126 | 127 | ## Notes 128 | 129 | - ChronoServer drives **all server-side replication** 130 | - **Players are registered automatically** 131 | - **NPCs require manual registration** with `RegisterNPC()` 132 | - `PushNPCTransform` **does not move the model on the server**, only updates snapshots for clients 133 | - **Disabling default replication** requires: 134 | - `Config.DISABLE_DEFAULT_REPLICATION = true` 135 | - Optional **dummy clones** for client visualization 136 | 137 | --- 138 | -------------------------------------------------------------------------------- /docs/Learn/Interpolation-Buffer.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Interpolation Buffer 3 | outline: deep 4 | --- 5 | 6 | # Interpolation Buffer 7 | 8 | An interpolation buffer is a time period for holding onto snapshots. 9 | 10 | When using unreliables and sending cframes rapidly, there is bound to be packet loss, or delayed packets. 11 | 12 | Interpolation delays gives you the time needed to reorder packets, receive packets and deal with packet loss. 13 | 14 | ::: warning Roblox Default 15 | Roblox has an interpolation delay **floor of 200ms**, but this is a heavy overkill when you are playing in low latency without packet loss. 16 | ::: 17 | 18 | I will be showing how to create your own **"dynamic" interpolation buffer**. 19 | 20 | --- 21 | 22 | ## How It Works 23 | 24 | To implement a dynamic interpolation buffer, you would need to **"predict"** when a packet gets dropped or received in an incorrect order. 25 | 26 | This may sound complicated at first, but it is actually very simple due to the fact that you can easily have an **"estimated arrival" prediction**. 27 | 28 | Estimated arrivals should be based on 2 things: 29 | 30 | 1. **Replication frequency** 31 | 2. **Latency** 32 | 33 | --- 34 | 35 | ### Replication Frequency 36 | Your replication frequency is the general tick rate you want to send your CFrames. 37 | ::: tip Recommended Frequency 38 | I recommend having a frequency of **20–40Hz** for the best results. 39 | ::: 40 | 41 | --- 42 | 43 | ### Latency 44 | 45 | Your latency should be calculated as the **recent average** of the time differences (offsets) between when packets are sent and when they arrive. 46 | This gives you an **expectation** of how long packets usually take to reach you. 47 | 48 | 49 | --- 50 | 51 | ### Detecting Packet Drops 52 | 53 | Using the average latency, you can detect if a packet has likely been dropped **if it doesn't arrive within the expected time window**. 54 | 55 | 56 | ::: tip Delayed Packets 57 | Suppose a packet does eventually arrive, but **in the next replication frame**, along with the packet that was originally scheduled for that frame. 58 | 59 | In that case, you can **assume that the late packet is the one you dropped**. 60 | 61 | You can confirm this by checking if **the gap between the delayed packet and the expected one is smaller than your average latency**. 62 | ::: 63 | 64 | --- 65 | 66 | ## Example Implementation 67 | 68 | Below is an example of the implementation in **chrono**: 69 | 70 | ```lua 71 | local RenderCache = require(script.Parent.renderCache) 72 | 73 | return function(minBuffer: number, maxBuffer: number, alpha: number) 74 | --average latency will be the average RTT between the client & server 75 | local playerLatencies = {} :: { [number]: { averageLatency: number, deviation: number, lastLatency: number? } } 76 | local fix = 0.2 77 | local recovery = 0.5 78 | 79 | local function RegisterPacket(networkId: number, serverTime: number, tickRate: number) 80 | local clientNow = RenderCache.GetEstimatedServerTime(networkId) 81 | local latency = clientNow - serverTime 82 | if latency > 1 then 83 | playerLatencies[networkId] = nil 84 | RenderCache.Remove(networkId) 85 | RenderCache.Add(networkId) 86 | 87 | warn(`{networkId} latency too high, cleared cache to repredict in case of error:! {latency}`) 88 | end 89 | if not playerLatencies[networkId] then 90 | --using the difference between current and last latency, i could smooth out the deviation (the variation in latency, which correlates to packet loss) 91 | --this is using statistics https://en.wikipedia.org/wiki/Moving_average 92 | playerLatencies[networkId] = { averageLatency = latency, deviation = 0, lastLatency = latency } 93 | return 94 | end 95 | 96 | local data = playerLatencies[networkId] 97 | if data.lastLatency then 98 | local delta = math.abs(latency - data.lastLatency) 99 | data.deviation = data.deviation * (1 - fix) + delta * fix 100 | end 101 | 102 | data.averageLatency = data.averageLatency * (1 - alpha) + latency * alpha 103 | data.lastLatency = latency 104 | end 105 | 106 | local function GetBuffer(networkId: number, tickRate: number) 107 | --calculate the interpolation buffer that accounts for expected latency, possible deviations and recovery from delays 108 | --the buffer will be per player 109 | local data = playerLatencies[networkId] 110 | if not data then 111 | return minBuffer 112 | end 113 | 114 | local recoveryMargin = tickRate * recovery 115 | local rawBuffer = data.averageLatency + data.deviation + recoveryMargin 116 | 117 | local buffer = if rawBuffer < minBuffer then minBuffer + (minBuffer - rawBuffer) * 0.2 else rawBuffer 118 | if buffer > maxBuffer then 119 | warn(`Interpolation buffer exceeded max! Was {buffer}, clamped to {maxBuffer}`) 120 | buffer = maxBuffer 121 | end 122 | 123 | return buffer 124 | end 125 | 126 | return { 127 | RegisterPacket = RegisterPacket, 128 | GetBuffer = GetBuffer, 129 | PlayerLatencies = playerLatencies, 130 | } 131 | end 132 | -------------------------------------------------------------------------------- /docs/API/Character.md: -------------------------------------------------------------------------------- 1 | # Characters 2 | 3 | The **Characters** module manages custom player characters across server and client. 4 | It handles **registration, cloning, storage, and replication** of player models, 5 | as well as signals for character lifecycle events. 6 | 7 | When required, Characters automatically: 8 | 9 | - Creates a container (`PlayerCharacterStorage`) in parented to the `Camera` so it doesn't replicate. 10 | - Creates or waits for a `PLAYER_MODEL_CACHE` folder in `ReplicatedStorage` to hold **replicated player models**. 11 | - Connects to: 12 | - **Server:** `Players.PlayerRemoving` to clean up models. 13 | - **Client:** `ChildAdded` on `PLAYER_MODEL_CACHE` to sync characters. 14 | 15 | --- 16 | 17 | ## Shared API 18 | 19 | ### `Characters.GetCharacter(player: Player) → Model?` 20 | 21 | Returns the **current character model** registered for the given player, 22 | or `nil` if no character is set. 23 | 24 | --- 25 | 26 | ### `Characters.SetCharacter(player: Player, model: Model | string, data: any?)` 27 | 28 | Assigns a character model to the given player. 29 | 30 | | Parameter | Type | Description | 31 | |-----------|-----------------|-----------------------------------------------------------------------------| 32 | | `player` | `Player` | The player whose character is being set. | 33 | | `model` | `Model`/`string`| The character model instance, or a key referencing a cached model in `Config.PLAYER_MODELS`. | 34 | | `data` | `any?` | Optional initialization data. Serialized via `HttpService:JSONEncode` when replicated. | 35 | 36 | - If a **string key** is passed, the model is looked up in `Config.PLAYER_MODELS`. 37 | - The model is cloned and parented to `PlayerCharacterStorage`. 38 | - If the player already had a character, it is cleaned up and `CharacterRemoved` is fired. 39 | - On the **server**, a clone is inserted into `ReplicatedStorage.PLAYER_MODEL_CACHE` to sync with the clients. 40 | - On the **client**, incoming clones from the cache are registered automatically. 41 | 42 | :::danger 43 | If `data` is provided, ensure it is JSON-serializable. 44 | ::: 45 | 46 | --- 47 | 48 | ## Signals 49 | 50 | These fire on both the server and client. 51 | 52 | ### `Characters.CharacterAdded(player: Player, model: Model, data: any)` 53 | 54 | Fired when a new character is registered for a player. 55 | 56 | --- 57 | 58 | ### `Characters.CharacterRemoved(player: Player, model: Model)` 59 | 60 | Fired when a character is removed (e.g. when replaced or on `PlayerRemoving`). 61 | 62 | --- 63 | 64 | ## Internal Behavior 65 | 66 | - Models are parented to a hidden **`Camera`** in `workspace` to prevent them from replicating automatically. 67 | - On the **server**, each player’s character is cloned into `PLAYER_MODEL_CACHE` 68 | with their `UserId` as the name for identification. 69 | - On the **client**, clones from `PLAYER_MODEL_CACHE` are consumed, registered, and removed. 70 | - When a player leaves, their model is destroyed and `CharacterRemoved` is fired. 71 | 72 | --- 73 | 74 | ## Usage Example 75 | 76 | ```lua 77 | --Enable custom characters, else the character system would not work 78 | require(ReplicatedStorage.Packages.chrono).Config.ENABLE_CUSTOM_CHARACTERS = true 79 | local Characters = require(ReplicatedStorage.Packages.chrono).Characters 80 | 81 | --Character lifecycles: 82 | Characters.CharacterAdded:Connect(function(player, model, data) 83 | print("Character added:", player.Name, model, data) 84 | end) 85 | 86 | Characters.CharacterRemoved:Connect(function(player, model) 87 | print("Character removed:", player.Name, model) 88 | end) 89 | 90 | --On the server, set a custom character for Chrono to manage 91 | local player = game.Players.PlayerAdded:Wait() 92 | Characters.SetCharacter(player, "paris", { skidding = true }) 93 | 94 | --On the client (or server), get the character Chrono is managing 95 | local character = Characters.GetCharacter(game.Players.LocalPlayer) 96 | 97 | --I recommend setting up your own custom character system and calling chrono so that they can still be custom replicated, rather than depending on chrono to get your characters. We designed chrono's api to be simple and flexible for exactly that. 98 | ``` 99 | 100 | 101 | # Important Notes on Reset Behavior 102 | 103 | When using **custom characters** with the `Characters` module, the default Roblox reset flow no longer works. 104 | 105 | - If a player **resets** while using a custom character: 106 | - The character will **not respawn** automatically. 107 | - On the **client**, the character will remain in a "dead" state. 108 | - On the **server**, the character will still be considered alive and will not be replaced. 109 | 110 | 111 | :::danger 112 | If you enable `Config.ENABLE_CUSTOM_CHARACTERS`, you must implement your own **respawn / reset logic**. 113 | Roblox’s built-in reset button will no longer respawn the player automatically. 114 | ::: 115 | 116 | --- -------------------------------------------------------------------------------- /docs/API/NpcRegistry.md: -------------------------------------------------------------------------------- 1 | # NpcRegistry 2 | 3 | ## Initialization 4 | 5 | When required, NpcRegistry automatically: 6 | 7 | - Creates a **`NpcRegistryCamera`** under `workspace` to hold **client clones**. 8 | - Creates or waits for a **`NPC_MODEL_CACHE`** folder in `ReplicatedStorage` for **replicated NPC models**. 9 | - Connects to: 10 | 11 | - **Server:** `RunService.PostSimulation` for automatic NPC transform updates. 12 | - **Client:** `AttributeChanged` on `NPC_MODEL_CACHE` for automatic client registration. 13 | 14 | --- 15 | 16 | ## Shared API 17 | 18 | ### `Npc.RegisterNpcModel(model: Model, npcModelType: string) → number` 19 | 20 | Adds the NPC model to the config. Used for creating a copy on the client. 21 | 22 | | Parameter | Type | Description | 23 | |--------------------|-----------|--------------------------------------------------------------| 24 | | `model` | `Model` | The NPC model to register. | 25 | | `npcModelType` | `string` | The type of the NPC model. | 26 | 27 | :::danger 28 | Make sure the model is set on both the server and the client. 29 | ::: 30 | 31 | --- 32 | 33 | ### `Npc.GetModel(id: number) → Model?` 34 | 35 | Returns the **original model** for a given NPC ID, if registered. 36 | 37 | --- 38 | 39 | ## Server API 40 | 41 | ### `Npc.Register(model: Model, npcType: string?, modelType: string?, automaticUpdate: boolean?,initData:any?) → number` 42 | 43 | Registers an NPC on the server. 44 | 45 | | Parameter | Type | Description | 46 | |--------------------|-----------|--------------------------------------------------------------| 47 | | `model` | `Model` | The NPC model to register. | 48 | | `npcType` | `string?` | Optional NPC type, defaults to `"DEFAULT"`. | 49 | | `modelType` | `string?` | Optional model type, if defined in config then that will use that as the model other wise `model:clone()` will be used. | 50 | | `automaticUpdate` | `boolean?`| If true, the server will automatically replicate cframe changes.| 51 | | `initData` | `any?` | Optional data to be passed along during initialization. Sent to `Npc.NpcAdded` | 52 | 53 | **Returns:** 54 | The id for the registered NPC. 55 | 56 | :::danger 57 | If `initData` is given, the data will be serialized using `HttpService:JSONEncode()`. Be aware of what can and cannot be serialized. 58 | ::: 59 | 60 | 61 | --- 62 | 63 | ### `Npc.UnRegister(idOrModel: number | Model) → Model` 64 | 65 | Unregisters an NPC from the server and cleans up its cache. 66 | 67 | | Parameter | Type | Description | 68 | |-----------------|-----------------|-----------------------------------------| 69 | | `idOrModel` | `number | Model` | NPC ID or the registered model. | 70 | 71 | **Returns:** 72 | The original NPC `Model`. 73 | 74 | 75 | 76 | ## Signals 77 | 78 | These will fired on both the server and client. 79 | 80 | :::danger 81 | These will only fire if the entity is registered via `NpcRegistry`. Manual Registration using `ChronoServer.RegisterNPC` will not trigger these signals. 82 | ::: 83 | 84 | 85 | ### `Npc.NpcAdded` 86 | 87 | Fired when a new NPC is added. 88 | 89 | | Parameter | Type | Description | 90 | |-----------|--------|---------------------------| 91 | | `npcId` | number | The ID of the added NPC. | 92 | | `model` | Model | The model of the added NPC. | 93 | | `data` | any | The initialization data. | 94 | 95 | --- 96 | 97 | ### `Npc.NpcRemoved` 98 | 99 | Fired when an NPC is removed. 100 | 101 | 102 | | Parameter | Type | Description | 103 | |-----------|--------|---------------------------| 104 | | `npcId` | number | The ID of the removed NPC. | 105 | | `model` | Model | The model of the removed NPC. | 106 | 107 | --- 108 | 109 | ## Client Behavior 110 | 111 | On the **client**, NpcRegistry: 112 | 113 | 1. Clones each NPC into `workspace.NpcRegistryCamera` for rendering. 114 | 2. Registers the clone with `ClientReplicate` for **smooth interpolation**. 115 | 3. Destroys the clone and unregisters automatically when the NPC is removed. 116 | 117 | --- 118 | 119 | :::danger 120 | Animations are non replicated from server to client. If you want NPC animations to be visible, you must play them on the clients. This is also idiomatic as it optimizes roblox transforms. 121 | ::: 122 | 123 | ## Usage Example 124 | 125 | ```lua 126 | local NpcRegistry = require(ReplicatedStorage.Packages.chrono).NpcRegistry 127 | 128 | NpcRegistry.NpcAdded:Connect(function(npcId, model, data) 129 | print("NPC added:", npcId, model, data) 130 | end) 131 | 132 | NpcRegistry.NpcRemoved:Connect(function(npcId, model) 133 | print("NPC removed:", npcId, model) 134 | end) 135 | 136 | --Register npc on server 137 | local npcModel = workspace:WaitForChild("Goblin") 138 | NpcRegistry.Register(npcModel, "DEFAULT", nil, true, { EyeColor = "Green" }) 139 | 140 | --Change cframe 141 | npcModel:PivotTo(CFrame.new(Vector3.new(20, 5, -20))) 142 | 143 | --Unregister for cleanup 144 | NpcRegistry.UnRegister(npcModel) 145 | ``` 146 | 147 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Customize default theme styling by overriding CSS variables: 3 | * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css 4 | */ 5 | 6 | /** 7 | * Colors 8 | * 9 | * Each colors have exact same color scale system with 3 levels of solid 10 | * colors with different brightness, and 1 soft color. 11 | * 12 | * - `XXX-1`: The most solid color used mainly for colored text. It must 13 | * satisfy the contrast ratio against when used on top of `XXX-soft`. 14 | * 15 | * - `XXX-2`: The color used mainly for hover state of the button. 16 | * 17 | * - `XXX-3`: The color for solid background, such as bg color of the button. 18 | * It must satisfy the contrast ratio with pure white (#ffffff) text on 19 | * top of it. 20 | * 21 | * - `XXX-soft`: The color used for subtle background such as custom container 22 | * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors 23 | * on top of it. 24 | * 25 | * The soft color must be semi transparent alpha channel. This is crucial 26 | * because it allows adding multiple "soft" colors on top of each other 27 | * to create a accent, such as when having inline code block inside 28 | * custom containers. 29 | * 30 | * - `default`: The color used purely for subtle indication without any 31 | * special meanings attached to it such as bg color for menu hover state. 32 | * 33 | * - `brand`: Used for primary brand colors, such as link text, button with 34 | * brand theme, etc. 35 | * 36 | * - `tip`: Used to indicate useful information. The default theme uses the 37 | * brand color for this by default. 38 | * 39 | * - `warning`: Used to indicate warning to the users. Used in custom 40 | * container, badges, etc. 41 | * 42 | * - `danger`: Used to show error, or dangerous message to the users. Used 43 | * in custom container, badges, etc. 44 | * -------------------------------------------------------------------------- */ 45 | 46 | :root { 47 | --vp-c-default-1: var(--vp-c-gray-1); 48 | --vp-c-default-2: var(--vp-c-gray-2); 49 | --vp-c-default-3: var(--vp-c-gray-3); 50 | --vp-c-default-soft: var(--vp-c-gray-soft); 51 | 52 | --vp-c-brand-1: var(--vp-c-indigo-1); 53 | --vp-c-brand-2: var(--vp-c-indigo-2); 54 | --vp-c-brand-3: var(--vp-c-indigo-3); 55 | --vp-c-brand-soft: var(--vp-c-indigo-soft); 56 | 57 | --vp-c-tip-1: var(--vp-c-brand-1); 58 | --vp-c-tip-2: var(--vp-c-brand-2); 59 | --vp-c-tip-3: var(--vp-c-brand-3); 60 | --vp-c-tip-soft: var(--vp-c-brand-soft); 61 | 62 | --vp-c-warning-1: var(--vp-c-yellow-1); 63 | --vp-c-warning-2: var(--vp-c-yellow-2); 64 | --vp-c-warning-3: var(--vp-c-yellow-3); 65 | --vp-c-warning-soft: var(--vp-c-yellow-soft); 66 | 67 | --vp-c-danger-1: var(--vp-c-red-1); 68 | --vp-c-danger-2: var(--vp-c-red-2); 69 | --vp-c-danger-3: var(--vp-c-red-3); 70 | --vp-c-danger-soft: var(--vp-c-red-soft); 71 | } 72 | 73 | /** 74 | * Component: Button 75 | * -------------------------------------------------------------------------- */ 76 | 77 | :root { 78 | --vp-button-brand-border: transparent; 79 | --vp-button-brand-text: var(--vp-c-white); 80 | --vp-button-brand-bg: var(--vp-c-brand-3); 81 | --vp-button-brand-hover-border: transparent; 82 | --vp-button-brand-hover-text: var(--vp-c-white); 83 | --vp-button-brand-hover-bg: var(--vp-c-brand-2); 84 | --vp-button-brand-active-border: transparent; 85 | --vp-button-brand-active-text: var(--vp-c-white); 86 | --vp-button-brand-active-bg: var(--vp-c-brand-1); 87 | } 88 | 89 | /** 90 | * Component: Home 91 | * -------------------------------------------------------------------------- */ 92 | :root { 93 | --vp-home-hero-name-color: transparent; 94 | --vp-home-hero-name-background: -webkit-linear-gradient(120deg, 95 | #53ffff 30%, 96 | #4833ff); 97 | 98 | --vp-home-hero-image-background-image: radial-gradient( 99 | circle at center, 100 | rgba(83, 255, 255, 0.65) 0%, 101 | rgba(110, 160, 255, 0.55) 30%, 102 | rgba(100, 90, 255, 0.45) 55%, 103 | rgba(80, 70, 200, 0.30) 75% 104 | ); 105 | --vp-home-hero-image-filter: blur(32px); 106 | } 107 | 108 | html.dark { 109 | --vp-home-hero-name-background: -webkit-linear-gradient(120deg, 110 | #53ffff 30%, 111 | #4833ff); 112 | 113 | --vp-home-hero-image-background-image: radial-gradient( 114 | circle at center, 115 | rgba(100, 255, 255, 0.85) 0%, 116 | rgba(90, 130, 255, 0.75) 25%, 117 | rgba(85, 70, 255, 0.65) 55%, 118 | rgba(60, 40, 200, 0.50) 80% 119 | ); 120 | --vp-home-hero-image-filter: blur(60px); 121 | } 122 | 123 | 124 | @media (min-width: 640px) { 125 | :root { 126 | --vp-home-hero-image-filter: blur(56px); 127 | } 128 | } 129 | 130 | @media (min-width: 960px) { 131 | :root { 132 | --vp-home-hero-image-filter: blur(68px); 133 | } 134 | } 135 | 136 | /** 137 | * Component: Custom Block 138 | * -------------------------------------------------------------------------- */ 139 | 140 | :root { 141 | --vp-custom-block-tip-border: transparent; 142 | --vp-custom-block-tip-text: var(--vp-c-text-1); 143 | --vp-custom-block-tip-bg: var(--vp-c-brand-soft); 144 | --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft); 145 | } 146 | 147 | /** 148 | * Component: Algolia 149 | * -------------------------------------------------------------------------- */ 150 | 151 | .DocSearch { 152 | --docsearch-primary-color: var(--vp-c-brand-1) !important; 153 | } -------------------------------------------------------------------------------- /src/Shared/Snapshots.luau: -------------------------------------------------------------------------------- 1 | export type SnapshotData = { 2 | t: number, 3 | value: Value, 4 | velocity: Velocity, 5 | } 6 | 7 | export type Snapshot = { 8 | cache: { SnapshotData }, 9 | head: number, 10 | count: number, 11 | Lerp: (p0: Vector3, p1: Vector3, v0: Velocity, v1: Velocity, t: number, dt: number) -> Vector3, 12 | 13 | Push: (self: Snapshot, t: number, value: Value, velocity: Velocity) -> (), 14 | GetLatest: (self: Snapshot) -> SnapshotData?, 15 | GetAt: (self: Snapshot, t: number) -> Value?, 16 | Clear: (self: Snapshot) -> (), 17 | } 18 | 19 | local Config = require(script.Parent.Parent.Shared.Config) 20 | 21 | local MAX_LENGTH = 30 22 | local SHOW_WARNINGS = Config.SHOW_WARNINGS 23 | 24 | local function WarnIfEnabled(...) 25 | if SHOW_WARNINGS then 26 | warn(...) 27 | end 28 | end 29 | 30 | local function GetBufferIndex(head: number, relativeIndex: number): number 31 | return (head + relativeIndex - 2) % MAX_LENGTH + 1 32 | end 33 | 34 | local function BinarySearchInsertionPoint(self: Snapshot, timeStamp: number): number 35 | local count = self.count 36 | if count == 0 then 37 | return 1 38 | end 39 | 40 | local left = 1 41 | local right = count 42 | local cache = self.cache 43 | local head = self.head 44 | 45 | while left <= right do 46 | local mid = (left + right) // 2 47 | local bufferIndex = GetBufferIndex(head, mid) 48 | local midTime = cache[bufferIndex].t 49 | 50 | if midTime < timeStamp then 51 | left = mid + 1 52 | else 53 | right = mid - 1 54 | end 55 | end 56 | 57 | return left 58 | end 59 | 60 | local function ShiftElementsRight(self: Snapshot, startPos: number, endPos: number) 61 | local cache = self.cache 62 | local head = self.head 63 | 64 | for i = endPos, startPos, -1 do 65 | local fromIndex = GetBufferIndex(head, i) 66 | local toIndex = GetBufferIndex(head, i + 1) 67 | cache[toIndex] = cache[fromIndex] 68 | end 69 | end 70 | 71 | local function Push(self: Snapshot, timeStamp: number, value: Value, velocity: Velocity) 72 | local newData = { t = timeStamp, value = value, velocity = velocity } 73 | 74 | if self.count == 0 then 75 | self.cache[self.head] = newData 76 | self.count = 1 77 | return 78 | end 79 | 80 | local insertionPos = BinarySearchInsertionPoint(self, timeStamp) 81 | 82 | if insertionPos > self.count then 83 | if self.count < MAX_LENGTH then 84 | local newIndex = GetBufferIndex(self.head, self.count + 1) 85 | self.cache[newIndex] = newData 86 | self.count += 1 87 | else 88 | local newIndex = self.head 89 | self.cache[newIndex] = newData 90 | self.head = self.head % MAX_LENGTH + 1 91 | end 92 | else 93 | if self.count < MAX_LENGTH then 94 | ShiftElementsRight(self, insertionPos, self.count) 95 | local insertIndex = GetBufferIndex(self.head, insertionPos) 96 | self.cache[insertIndex] = newData 97 | self.count += 1 98 | else 99 | for i = 1, insertionPos - 1 do 100 | local fromIndex = GetBufferIndex(self.head, i + 1) 101 | local toIndex = GetBufferIndex(self.head, i) 102 | self.cache[toIndex] = self.cache[fromIndex] 103 | end 104 | local insertIndex = GetBufferIndex(self.head, insertionPos) 105 | self.cache[insertIndex] = newData 106 | end 107 | end 108 | end 109 | 110 | local function GetLatest(self: Snapshot): SnapshotData? 111 | if self.count == 0 then 112 | return nil 113 | end 114 | 115 | local lastIndex = GetBufferIndex(self.head, self.count) 116 | return self.cache[lastIndex] 117 | end 118 | 119 | local function GetAt(self: Snapshot, at: number): Value? 120 | local count = self.count 121 | if count == 0 then 122 | return nil 123 | end 124 | 125 | if count == 1 then 126 | local index = GetBufferIndex(self.head, 1) 127 | return self.cache[index].value 128 | end 129 | 130 | local cache = self.cache 131 | local head = self.head 132 | local lerpFunction = self.Lerp 133 | 134 | local insertionPos = BinarySearchInsertionPoint(self, at) 135 | 136 | local beforePos = insertionPos - 1 137 | local afterPos = insertionPos 138 | 139 | local beforeData: SnapshotData? 140 | local afterData: SnapshotData? 141 | 142 | if beforePos >= 1 and beforePos <= count then 143 | local beforeIndex = GetBufferIndex(head, beforePos) 144 | beforeData = cache[beforeIndex] 145 | end 146 | 147 | if afterPos >= 1 and afterPos <= count then 148 | local afterIndex = GetBufferIndex(head, afterPos) 149 | afterData = cache[afterIndex] 150 | end 151 | 152 | if beforeData and afterData then 153 | local dt = afterData.t - beforeData.t 154 | if dt == 0 then 155 | return beforeData.value 156 | end 157 | local alpha = (at - beforeData.t) / dt 158 | 159 | local p0, p1 = (beforeData.value :: any).Position, (afterData.value :: any).Position 160 | local v0, v1 = beforeData.velocity, afterData.velocity 161 | 162 | local position = lerpFunction(p0, p1, v0, v1, alpha, dt) 163 | local delta = (afterData.value :: any).Rotation * (beforeData.value :: any).Rotation:Inverse() 164 | local axis, angle = delta:ToAxisAngle() 165 | local rotation = (beforeData.value :: any).Rotation * CFrame.fromAxisAngle(axis, angle * alpha) 166 | 167 | return CFrame.new(position) * rotation 168 | elseif beforeData then 169 | WarnIfEnabled("Tried to fetch a time that was ahead of snapshot storage!") 170 | return beforeData.value 171 | elseif afterData then 172 | WarnIfEnabled("Tried to fetch a time that was behind snapshot storage!") 173 | return afterData.value 174 | end 175 | 176 | return nil 177 | end 178 | 179 | local function Clear(self: Snapshot) 180 | self.head = 1 181 | self.count = 0 182 | end 183 | 184 | local function New( 185 | lerpFunction: (Vector3, Vector3, Velocity, Velocity, number, number) -> Vector3 186 | ): Snapshot 187 | local cache = table.create(MAX_LENGTH) 188 | for i = 1, MAX_LENGTH do 189 | cache[i] = { t = 0, value = (nil :: any) :: Value, velocity = (nil :: any) :: Velocity } 190 | end 191 | 192 | return { 193 | cache = cache, 194 | head = 1, 195 | count = 0, 196 | Lerp = lerpFunction, 197 | 198 | Push = Push, 199 | GetLatest = GetLatest, 200 | GetAt = GetAt, 201 | Clear = Clear, 202 | } 203 | end 204 | 205 | return New 206 | -------------------------------------------------------------------------------- /docs/.vitepress/cache/deps/vue.js: -------------------------------------------------------------------------------- 1 | import { 2 | BaseTransition, 3 | BaseTransitionPropsValidators, 4 | Comment, 5 | DeprecationTypes, 6 | EffectScope, 7 | ErrorCodes, 8 | ErrorTypeStrings, 9 | Fragment, 10 | KeepAlive, 11 | ReactiveEffect, 12 | Static, 13 | Suspense, 14 | Teleport, 15 | Text, 16 | TrackOpTypes, 17 | Transition, 18 | TransitionGroup, 19 | TriggerOpTypes, 20 | VueElement, 21 | assertNumber, 22 | callWithAsyncErrorHandling, 23 | callWithErrorHandling, 24 | camelize, 25 | capitalize, 26 | cloneVNode, 27 | compatUtils, 28 | compile, 29 | computed, 30 | createApp, 31 | createBaseVNode, 32 | createBlock, 33 | createCommentVNode, 34 | createElementBlock, 35 | createHydrationRenderer, 36 | createPropsRestProxy, 37 | createRenderer, 38 | createSSRApp, 39 | createSlots, 40 | createStaticVNode, 41 | createTextVNode, 42 | createVNode, 43 | customRef, 44 | defineAsyncComponent, 45 | defineComponent, 46 | defineCustomElement, 47 | defineEmits, 48 | defineExpose, 49 | defineModel, 50 | defineOptions, 51 | defineProps, 52 | defineSSRCustomElement, 53 | defineSlots, 54 | devtools, 55 | effect, 56 | effectScope, 57 | getCurrentInstance, 58 | getCurrentScope, 59 | getCurrentWatcher, 60 | getTransitionRawChildren, 61 | guardReactiveProps, 62 | h, 63 | handleError, 64 | hasInjectionContext, 65 | hydrate, 66 | hydrateOnIdle, 67 | hydrateOnInteraction, 68 | hydrateOnMediaQuery, 69 | hydrateOnVisible, 70 | initCustomFormatter, 71 | initDirectivesForSSR, 72 | inject, 73 | isMemoSame, 74 | isProxy, 75 | isReactive, 76 | isReadonly, 77 | isRef, 78 | isRuntimeOnly, 79 | isShallow, 80 | isVNode, 81 | markRaw, 82 | mergeDefaults, 83 | mergeModels, 84 | mergeProps, 85 | nextTick, 86 | normalizeClass, 87 | normalizeProps, 88 | normalizeStyle, 89 | onActivated, 90 | onBeforeMount, 91 | onBeforeUnmount, 92 | onBeforeUpdate, 93 | onDeactivated, 94 | onErrorCaptured, 95 | onMounted, 96 | onRenderTracked, 97 | onRenderTriggered, 98 | onScopeDispose, 99 | onServerPrefetch, 100 | onUnmounted, 101 | onUpdated, 102 | onWatcherCleanup, 103 | openBlock, 104 | popScopeId, 105 | provide, 106 | proxyRefs, 107 | pushScopeId, 108 | queuePostFlushCb, 109 | reactive, 110 | readonly, 111 | ref, 112 | registerRuntimeCompiler, 113 | render, 114 | renderList, 115 | renderSlot, 116 | resolveComponent, 117 | resolveDirective, 118 | resolveDynamicComponent, 119 | resolveFilter, 120 | resolveTransitionHooks, 121 | setBlockTracking, 122 | setDevtoolsHook, 123 | setTransitionHooks, 124 | shallowReactive, 125 | shallowReadonly, 126 | shallowRef, 127 | ssrContextKey, 128 | ssrUtils, 129 | stop, 130 | toDisplayString, 131 | toHandlerKey, 132 | toHandlers, 133 | toRaw, 134 | toRef, 135 | toRefs, 136 | toValue, 137 | transformVNodeArgs, 138 | triggerRef, 139 | unref, 140 | useAttrs, 141 | useCssModule, 142 | useCssVars, 143 | useHost, 144 | useId, 145 | useModel, 146 | useSSRContext, 147 | useShadowRoot, 148 | useSlots, 149 | useTemplateRef, 150 | useTransitionState, 151 | vModelCheckbox, 152 | vModelDynamic, 153 | vModelRadio, 154 | vModelSelect, 155 | vModelText, 156 | vShow, 157 | version, 158 | warn, 159 | watch, 160 | watchEffect, 161 | watchPostEffect, 162 | watchSyncEffect, 163 | withAsyncContext, 164 | withCtx, 165 | withDefaults, 166 | withDirectives, 167 | withKeys, 168 | withMemo, 169 | withModifiers, 170 | withScopeId 171 | } from "./chunk-HVR2FF6M.js"; 172 | export { 173 | BaseTransition, 174 | BaseTransitionPropsValidators, 175 | Comment, 176 | DeprecationTypes, 177 | EffectScope, 178 | ErrorCodes, 179 | ErrorTypeStrings, 180 | Fragment, 181 | KeepAlive, 182 | ReactiveEffect, 183 | Static, 184 | Suspense, 185 | Teleport, 186 | Text, 187 | TrackOpTypes, 188 | Transition, 189 | TransitionGroup, 190 | TriggerOpTypes, 191 | VueElement, 192 | assertNumber, 193 | callWithAsyncErrorHandling, 194 | callWithErrorHandling, 195 | camelize, 196 | capitalize, 197 | cloneVNode, 198 | compatUtils, 199 | compile, 200 | computed, 201 | createApp, 202 | createBlock, 203 | createCommentVNode, 204 | createElementBlock, 205 | createBaseVNode as createElementVNode, 206 | createHydrationRenderer, 207 | createPropsRestProxy, 208 | createRenderer, 209 | createSSRApp, 210 | createSlots, 211 | createStaticVNode, 212 | createTextVNode, 213 | createVNode, 214 | customRef, 215 | defineAsyncComponent, 216 | defineComponent, 217 | defineCustomElement, 218 | defineEmits, 219 | defineExpose, 220 | defineModel, 221 | defineOptions, 222 | defineProps, 223 | defineSSRCustomElement, 224 | defineSlots, 225 | devtools, 226 | effect, 227 | effectScope, 228 | getCurrentInstance, 229 | getCurrentScope, 230 | getCurrentWatcher, 231 | getTransitionRawChildren, 232 | guardReactiveProps, 233 | h, 234 | handleError, 235 | hasInjectionContext, 236 | hydrate, 237 | hydrateOnIdle, 238 | hydrateOnInteraction, 239 | hydrateOnMediaQuery, 240 | hydrateOnVisible, 241 | initCustomFormatter, 242 | initDirectivesForSSR, 243 | inject, 244 | isMemoSame, 245 | isProxy, 246 | isReactive, 247 | isReadonly, 248 | isRef, 249 | isRuntimeOnly, 250 | isShallow, 251 | isVNode, 252 | markRaw, 253 | mergeDefaults, 254 | mergeModels, 255 | mergeProps, 256 | nextTick, 257 | normalizeClass, 258 | normalizeProps, 259 | normalizeStyle, 260 | onActivated, 261 | onBeforeMount, 262 | onBeforeUnmount, 263 | onBeforeUpdate, 264 | onDeactivated, 265 | onErrorCaptured, 266 | onMounted, 267 | onRenderTracked, 268 | onRenderTriggered, 269 | onScopeDispose, 270 | onServerPrefetch, 271 | onUnmounted, 272 | onUpdated, 273 | onWatcherCleanup, 274 | openBlock, 275 | popScopeId, 276 | provide, 277 | proxyRefs, 278 | pushScopeId, 279 | queuePostFlushCb, 280 | reactive, 281 | readonly, 282 | ref, 283 | registerRuntimeCompiler, 284 | render, 285 | renderList, 286 | renderSlot, 287 | resolveComponent, 288 | resolveDirective, 289 | resolveDynamicComponent, 290 | resolveFilter, 291 | resolveTransitionHooks, 292 | setBlockTracking, 293 | setDevtoolsHook, 294 | setTransitionHooks, 295 | shallowReactive, 296 | shallowReadonly, 297 | shallowRef, 298 | ssrContextKey, 299 | ssrUtils, 300 | stop, 301 | toDisplayString, 302 | toHandlerKey, 303 | toHandlers, 304 | toRaw, 305 | toRef, 306 | toRefs, 307 | toValue, 308 | transformVNodeArgs, 309 | triggerRef, 310 | unref, 311 | useAttrs, 312 | useCssModule, 313 | useCssVars, 314 | useHost, 315 | useId, 316 | useModel, 317 | useSSRContext, 318 | useShadowRoot, 319 | useSlots, 320 | useTemplateRef, 321 | useTransitionState, 322 | vModelCheckbox, 323 | vModelDynamic, 324 | vModelRadio, 325 | vModelSelect, 326 | vModelText, 327 | vShow, 328 | version, 329 | warn, 330 | watch, 331 | watchEffect, 332 | watchPostEffect, 333 | watchSyncEffect, 334 | withAsyncContext, 335 | withCtx, 336 | withDefaults, 337 | withDirectives, 338 | withKeys, 339 | withMemo, 340 | withModifiers, 341 | withScopeId 342 | }; 343 | //# sourceMappingURL=vue.js.map 344 | -------------------------------------------------------------------------------- /src/Shared/Signal.luau: -------------------------------------------------------------------------------- 1 | --!nocheck 2 | -------------------------------------------------------------------------------- 3 | -- Batched Yield-Safe Signal Implementation -- 4 | -- This is a Signal class which has effectively identical behavior to a -- 5 | -- normal RBXScriptSignal, with the only difference being a couple extra -- 6 | -- stack frames at the bottom of the stack trace when an error is thrown. -- 7 | -- This implementation caches runner coroutines, so the ability to yield in -- 8 | -- the signal handlers comes at minimal extra cost over a naive signal -- 9 | -- implementation that either always or never spawns a thread. -- 10 | -- -- 11 | -- API: -- 12 | -- local Signal = require(THIS MODULE) -- 13 | -- local sig = Signal.new() -- 14 | -- local connection = sig:Connect(function(arg1, arg2, ...) ... end) -- 15 | -- sig:Fire(arg1, arg2, ...) -- 16 | -- connection:Disconnect() -- 17 | -- sig:DisconnectAll() -- 18 | -- local arg1, arg2, ... = sig:Wait() -- 19 | -- -- 20 | -- Licence: -- 21 | -- Licenced under the MIT licence. -- 22 | -- -- 23 | -- Authors: -- 24 | -- stravant - July 31st, 2021 - Created the file. -- 25 | -------------------------------------------------------------------------------- 26 | 27 | export type Signal = { 28 | Fire: (self: any, T...) -> (), 29 | DisconnectAll: (self: any) -> (), 30 | Event: Event, 31 | } 32 | 33 | export type Connection = { 34 | Disconnect: (self: any) -> (), 35 | Connected: boolean, 36 | } 37 | 38 | export type Event = { 39 | Connect: (self: any, FN: (T...) -> ()) -> Connection, 40 | Once: (self: any, FN: (T...) -> ()) -> Connection, 41 | Wait: (self: any) -> T..., 42 | } 43 | 44 | -- The currently idle thread to run the next handler on 45 | local freeRunnerThread = nil 46 | 47 | -- Function which acquires the currently idle handler runner thread, runs the 48 | -- function fn on it, and then releases the thread, returning it to being the 49 | -- currently idle one. 50 | -- If there was a currently idle runner thread already, that's okay, that old 51 | -- one will just get thrown and eventually GCed. 52 | local function acquireRunnerThreadAndCallEventHandler(fn, ...) 53 | local acquiredRunnerThread = freeRunnerThread 54 | freeRunnerThread = nil 55 | fn(...) 56 | -- The handler finished running, this runner thread is free again. 57 | freeRunnerThread = acquiredRunnerThread 58 | end 59 | 60 | -- Coroutine runner that we create coroutines of. The coroutine can be 61 | -- repeatedly resumed with functions to run followed by the argument to run 62 | -- them with. 63 | local function runEventHandlerInFreeThread() 64 | -- Note: We cannot use the initial set of arguments passed to 65 | -- runEventHandlerInFreeThread for a call to the handler, because those 66 | -- arguments would stay on the stack for the duration of the thread's 67 | -- existence, temporarily leaking references. Without access to raw bytecode 68 | -- there's no way for us to clear the "..." references from the stack. 69 | while true do 70 | acquireRunnerThreadAndCallEventHandler(coroutine.yield()) 71 | end 72 | end 73 | 74 | -- Connection class 75 | local Connection = {} 76 | Connection.__index = Connection 77 | 78 | function Connection.new(signal, fn, defer) 79 | return setmetatable({ 80 | _type_ = "Connection", 81 | Connected = true, 82 | _signal = signal, 83 | _fn = fn, 84 | _next = false, 85 | _defer = defer or false, 86 | }, Connection) 87 | end 88 | 89 | function Connection:Disconnect() 90 | self.Connected = false 91 | 92 | -- Unhook the node, but DON'T clear it. That way any fire calls that are 93 | -- currently sitting on this node will be able to iterate forwards off of 94 | -- it, but any subsequent fire calls will not hit it, and it will be GCed 95 | -- when no more fire calls are sitting on it. 96 | if self._signal._handlerListHead == self then 97 | self._signal._handlerListHead = self._next 98 | else 99 | local prev = self._signal._handlerListHead 100 | while prev and prev._next ~= self do 101 | prev = prev._next 102 | end 103 | if prev then 104 | prev._next = self._next 105 | end 106 | end 107 | end 108 | 109 | -- Make Connection strict 110 | setmetatable(Connection, { 111 | __index = function(tb, key) 112 | error(("Attempt to get Connection::%s (not a valid member)"):format(tostring(key)), 2) 113 | end, 114 | __newindex = function(tb, key, value) 115 | error(("Attempt to set Connection::%s (not a valid member)"):format(tostring(key)), 2) 116 | end, 117 | }) 118 | 119 | local Event = {} 120 | Event.__index = Event 121 | 122 | function Event.new() 123 | return setmetatable({ 124 | _type_ = "Event", 125 | _handlerListHead = false, 126 | }, Event) 127 | end 128 | 129 | function Event:Connect(fn) 130 | local connection = Connection.new(self, fn) 131 | if self._handlerListHead then 132 | connection._next = self._handlerListHead 133 | self._handlerListHead = connection 134 | else 135 | self._handlerListHead = connection 136 | end 137 | return connection 138 | end 139 | 140 | -- Disconnect all handlers. Since we use a linked list it suffices to clear the 141 | -- reference to the head handler. 142 | function Event:DisconnectAll() 143 | self._handlerListHead = false 144 | end 145 | 146 | -- Implement Signal:Wait() in terms of a temporary connection using 147 | -- a Signal:Connect() which disconnects itself. 148 | function Event:Wait(defer) 149 | local waitingCoroutine = coroutine.running() 150 | local cn 151 | cn = self:Connect(function(...) 152 | cn:Disconnect() 153 | task.spawn(waitingCoroutine, ...) 154 | end, defer) 155 | return coroutine.yield() 156 | end 157 | 158 | -- Implement Signal:Once() in terms of a connection which disconnects 159 | -- itself before running the handler. 160 | function Event:Once(fn, defer) 161 | local cn 162 | cn = self:Connect(function(...) 163 | if cn.Connected then 164 | cn:Disconnect() 165 | end 166 | fn(...) 167 | end, defer) 168 | return cn 169 | end 170 | 171 | -- Signal class 172 | local Signal = {} 173 | Signal.__index = Signal 174 | 175 | function Signal.new() 176 | return setmetatable({ 177 | _type_ = "Signal", 178 | Event = Event.new(), 179 | }, Signal) 180 | end 181 | 182 | -- Disconnect all handlers. Since we use a linked list it suffices to clear the 183 | -- reference to the head handler. 184 | function Signal:DisconnectAll() 185 | self.Event._handlerListHead = false 186 | end 187 | 188 | -- Signal:Fire(...) implemented by running the handler functions on the 189 | -- coRunnerThread, and any time the resulting thread yielded without returning 190 | -- to us, that means that it yielded to the Roblox scheduler and has been taken 191 | -- over by Roblox scheduling, meaning we have to make a new coroutine runner. 192 | function Signal:Fire(...) 193 | local item = self.Event._handlerListHead 194 | while item do 195 | if item.Connected then 196 | if item._defer then 197 | task.defer(item._fn, ...) 198 | else 199 | if not freeRunnerThread then 200 | freeRunnerThread = coroutine.create(runEventHandlerInFreeThread) 201 | -- Get the freeRunnerThread to the first yield 202 | coroutine.resume(freeRunnerThread) 203 | end 204 | task.spawn(freeRunnerThread, item._fn, ...) 205 | end 206 | end 207 | item = item._next 208 | end 209 | end 210 | 211 | -- Make signal strict 212 | setmetatable(Signal, { 213 | __index = function(tb, key) 214 | error(("Attempt to get Signal::%s (not a valid member)"):format(tostring(key)), 2) 215 | end, 216 | __newindex = function(tb, key, value) 217 | error(("Attempt to set Signal::%s (not a valid member)"):format(tostring(key)), 2) 218 | end, 219 | }) 220 | 221 | return Signal 222 | -------------------------------------------------------------------------------- /src/Shared/NpcRegistry.luau: -------------------------------------------------------------------------------- 1 | local HttpService = game:GetService("HttpService") 2 | local Players = game:GetService("Players") 3 | local RunService = game:GetService("RunService") 4 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 5 | 6 | local Cache = {} 7 | local Npc = {} 8 | local AutomaticNpc = {} 9 | 10 | local IS_CLIENT = RunService:IsClient() 11 | local IS_SERVER = RunService:IsServer() 12 | 13 | local ClientReplicate 14 | local ServerReplicate 15 | if IS_CLIENT then 16 | ClientReplicate = require(script.Parent.Parent.Client.Replicate) 17 | else 18 | ServerReplicate = require(script.Parent.Parent.Server.Replicate) 19 | end 20 | 21 | local Config = require(script.Parent.Config) 22 | local Signal = require(script.Parent.Signal) 23 | 24 | local NpcAdded: Signal.Signal = Signal.new() 25 | local NpcRemoved: Signal.Signal = Signal.new() 26 | 27 | local CAMERA = Instance.new("Camera", workspace) 28 | CAMERA.Name = "NpcRegistryCamera" 29 | local NPC_MODEL_CACHE 30 | local NpcRegistryRemote: RemoteEvent 31 | 32 | if IS_SERVER then 33 | NpcRegistryRemote = Instance.new("RemoteEvent", script) 34 | NpcRegistryRemote.Name = "OwnerChanged" 35 | NPC_MODEL_CACHE = Instance.new("Folder", ReplicatedStorage) 36 | NPC_MODEL_CACHE.Name = "NPC_MODEL_CACHE" 37 | elseif IS_CLIENT then 38 | NpcRegistryRemote = script:WaitForChild("OwnerChanged") :: RemoteEvent 39 | NPC_MODEL_CACHE = ReplicatedStorage:WaitForChild("NPC_MODEL_CACHE") :: any 40 | end 41 | 42 | local NPC_MODELS = Config.NPC_MODELS 43 | 44 | local ClientOwners = {} :: { [any]: any } 45 | 46 | local function removeOwner(client, id) 47 | if not ClientOwners[client] then 48 | return 49 | end 50 | ClientOwners[client][id] = nil 51 | if not next(ClientOwners[client]) then 52 | ClientOwners[client] = nil 53 | end 54 | end 55 | 56 | local function ClientRegister(id, model: Model, data: { type: string, initData: any? }) 57 | local npcType = data.type 58 | if not id then 59 | warn("NPC model does not have an NPC_ID attribute:", model:GetFullName()) 60 | return 61 | end 62 | 63 | local clone = model:Clone() 64 | clone.Name = tostring(id) 65 | clone.Parent = CAMERA 66 | --(clone.PrimaryPart :: any).Anchored = true 67 | 68 | Cache[id] = clone 69 | clone:PivotTo(CFrame.new(0, 1000000, 0)) 70 | 71 | ClientReplicate.RegisterClientNPC(id, clone, npcType) 72 | NpcAdded:Fire(id, clone, data.initData) 73 | 74 | NPC_MODEL_CACHE:SetAttribute(tostring(id), true) -- Mark as registered 75 | end 76 | 77 | local function Check() 78 | for id, strData in NPC_MODEL_CACHE:GetAttributes() do 79 | if not tonumber(id) or type(strData) ~= "string" then 80 | continue 81 | end 82 | 83 | local data = HttpService:JSONDecode(strData) 84 | local npc = NPC_MODEL_CACHE:FindFirstChild(tostring(id)) 85 | if data.modelType then 86 | npc = NPC_MODELS[data.modelType] or npc 87 | end 88 | 89 | if not npc or not npc:IsA("Model") then 90 | warn( 91 | `Npc cache: Invalid NPC Model for ID: {id} TYPE: {data.modelType} MAKE SURE IT IS REGISTERED AS A MODEL IN CONFIG [Chrono/src/shared/config]` 92 | ) 93 | continue 94 | end 95 | ClientRegister(tonumber(id) :: number, npc, data) 96 | end 97 | end 98 | 99 | function Npc.RegisterNpcModel(model: Model, npcModelType: string) 100 | NPC_MODELS[npcModelType] = model 101 | end 102 | 103 | function Npc.Register(model: Model, npcType: string?, npcModelType: string?, automaticUpdate: boolean?, initData: any?) 104 | if IS_CLIENT then 105 | error("Register can only be called on the server.") 106 | end 107 | if not model then 108 | error("Npc cache requires a model to register.") 109 | end 110 | if npcModelType and not NPC_MODELS[npcModelType] then 111 | warn( 112 | "No NPC Model Found [" 113 | .. tostring(npcModelType) 114 | .. "] MAKE SURE IT IS REGISTERED IN CONFIG [Chrono/src/shared/config] or using [Npc.RegisterNpcModel]" 115 | ) 116 | end 117 | npcType = npcType or "DEFAULT" 118 | local defaultModel = NPC_MODELS[npcModelType or ""] 119 | 120 | local folder_: string = npcModelType or "DEFAULT" 121 | local folder: Folder = (CAMERA:FindFirstChild(folder_) or Instance.new("Folder", CAMERA)) :: any 122 | folder.Name = folder_ 123 | 124 | local id = ServerReplicate.RegisterNPC(model, npcType) 125 | Cache[id] = model 126 | model.Parent = folder 127 | if model and model.PrimaryPart then 128 | ServerReplicate.PushNPCTransform(id, model.PrimaryPart.CFrame, os.clock()) 129 | end 130 | 131 | pcall(function() 132 | (model.PrimaryPart :: any):SetNetworkOwner(nil) 133 | end) 134 | 135 | if not defaultModel then 136 | if not model.Archivable then 137 | warn("NPC model is not archivable:", model:GetFullName(), "Archivable will now be set to true.") 138 | end 139 | model.Archivable = true 140 | local clone = model:Clone() --// MAKE SURE Archivable is set to true 141 | clone:PivotTo(CFrame.new(0, 1000000, 0)) 142 | clone.Name = tostring(id) 143 | clone.Parent = NPC_MODEL_CACHE 144 | end 145 | 146 | NPC_MODEL_CACHE:SetAttribute( 147 | tostring(id), 148 | HttpService:JSONEncode({ type = npcType, modelType = npcModelType, initData = initData }) 149 | ) -- Could use remotes instead in the future but this works aaaaaaaaaaaaa 150 | 151 | if automaticUpdate then 152 | AutomaticNpc[id] = model 153 | end 154 | NpcAdded:Fire(id, model, initData) 155 | 156 | return id 157 | end 158 | 159 | function Npc.UnRegister(idOrModel: number | Model): Model 160 | if IS_CLIENT then 161 | error("UnRegister can only be called on the server.") 162 | end 163 | local id = type(idOrModel) == "number" and idOrModel or (idOrModel :: Model):GetAttribute("NPC_ID") :: any 164 | if not id then 165 | error("Npc cache requires a valid NPC_ID to unregister.") 166 | end 167 | 168 | Cache[id] = nil 169 | AutomaticNpc[id] = nil 170 | local data = ServerReplicate.idMap[id] 171 | local owner = data.networkOwner 172 | 173 | removeOwner(owner, id) 174 | 175 | local actualModel = ServerReplicate.UnregisterNPC(id) 176 | 177 | local model = NPC_MODEL_CACHE:FindFirstChild(tostring(id)) 178 | if model then 179 | model:Destroy() 180 | end 181 | NPC_MODEL_CACHE:SetAttribute(tostring(id), nil) 182 | if not actualModel then 183 | error("Npc cache: Unregister failed to find model for ID: " .. id .. " Please Investigate.") 184 | end 185 | NpcRemoved:Fire(id, actualModel) 186 | return actualModel 187 | end 188 | 189 | function Npc.GetModel(id: number): Model 190 | return Cache[id] 191 | end 192 | 193 | function Npc.SetPosition(id: number, cframe: CFrame) 194 | if IS_CLIENT then 195 | local model = Cache[id] 196 | if model and model:IsA("Model") then 197 | model:PivotTo(cframe) 198 | end 199 | else 200 | local data = ServerReplicate.idMap[id] 201 | if not data then 202 | return 203 | end 204 | if data.networkOwner then 205 | NpcRegistryRemote:FireClient(data.networkOwner, "m", id, cframe) 206 | return 207 | end 208 | local model = Cache[id] 209 | if AutomaticNpc[id] and model and model:IsA("Model") then 210 | model:PivotTo(cframe) 211 | end 212 | ServerReplicate.PushNPCTransform(id, cframe, os.clock()) 213 | end 214 | end 215 | 216 | function Npc.SetNetworkOwner(id: number, player: Player?) 217 | if IS_CLIENT then 218 | error("SetNetworkOwner can only be called on the server.") 219 | end 220 | local data = ServerReplicate.idMap[id] 221 | if not data then 222 | warn("Npc cache: SetNetworkOwner failed to find data for ID: " .. id .. " Please Investigate.") 223 | return 224 | end 225 | local existingOwner = data.networkOwner 226 | if existingOwner == player then 227 | return 228 | elseif existingOwner then 229 | removeOwner(existingOwner, id) 230 | 231 | NpcRegistryRemote:FireClient(existingOwner, "r", id) 232 | end 233 | data.networkOwner = player 234 | NpcRegistryRemote:FireAllClients("c", id, player) 235 | local idMapData = ServerReplicate.idMap[id] 236 | if idMapData then 237 | idMapData.clientLastTick = nil 238 | local latest = idMapData.snapshot:GetLatest() 239 | idMapData.snapshot:Clear() 240 | if latest then 241 | idMapData.snapshot:Push(0, latest.value) 242 | end 243 | end 244 | if player then 245 | ClientOwners[player] = ClientOwners[player] or {} 246 | ClientOwners[player][id] = true 247 | NpcRegistryRemote:FireClient(player, "a", id) 248 | end 249 | end 250 | 251 | function Npc.GetNetworkOwner(id: number): Player? 252 | if IS_CLIENT then 253 | error("GetNetworkOwner can only be called on the server.") 254 | end 255 | local data = ServerReplicate.idMap[id] 256 | if not data then 257 | return 258 | end 259 | return data.networkOwner 260 | end 261 | 262 | function Npc.GetNpcsOwnedBy(player: Player): {}? 263 | if IS_CLIENT then 264 | error("GetNpcsOwnedBy can only be called on the server.") 265 | end 266 | local arr = {} 267 | if ClientOwners[player] then 268 | for id in ClientOwners[player] do 269 | table.insert(arr, id) 270 | end 271 | end 272 | return arr 273 | end 274 | 275 | function Npc.GetClientOwned() 276 | if not IS_CLIENT then 277 | error("GetClientOwned can only be called on the client.") 278 | end 279 | local arr = {} 280 | for id in ClientOwners do 281 | table.insert(arr, id) 282 | end 283 | return arr 284 | end 285 | 286 | if IS_CLIENT then 287 | task.delay(0.1, function() 288 | NPC_MODEL_CACHE.AttributeChanged:Connect(function(attribute) 289 | local id = tonumber(attribute) 290 | if not id then 291 | return 292 | end 293 | local strData = NPC_MODEL_CACHE:GetAttribute(attribute) 294 | if not strData then 295 | ClientReplicate.UnregisterNPC(id) 296 | local npc = Cache[id] 297 | Cache[id] = nil 298 | NpcRemoved:Fire(id, npc) 299 | npc:Destroy() 300 | elseif type(strData) == "string" then 301 | local data = HttpService:JSONDecode(strData) 302 | local npc = NPC_MODEL_CACHE:FindFirstChild(tostring(id)) 303 | if data.modelType then 304 | npc = NPC_MODELS[data.modelType] or npc 305 | end 306 | 307 | if not npc or not npc:IsA("Model") then 308 | warn( 309 | `Npc cache: Invalid NPC Model for ID: {id} TYPE: {data.modelType} MAKE SURE IT IS REGISTERED AS A MODEL IN CONFIG [Chrono/src/shared/config]` 310 | ) 311 | return 312 | end 313 | task.defer(ClientRegister, id, npc, data) 314 | end 315 | end) 316 | Check() 317 | NpcRegistryRemote.OnClientEvent:Connect(function(t, id, other) 318 | if t == "a" then 319 | ClientOwners[id] = ClientOwners[id] or true 320 | elseif t == "r" then 321 | ClientOwners[id] = nil 322 | elseif t == "i" then 323 | for v, data in id do 324 | v = tonumber(v) 325 | ClientOwners[v] = data 326 | 327 | local idData = ClientReplicate.idMap[v] 328 | 329 | if idData and typeof(data) == "CFrame" then 330 | idData.snapshot:Push(os.clock(), data) 331 | end 332 | end 333 | elseif t == "c" then 334 | task.wait(0.1) -- give some time for the server to update 335 | local idData = ClientReplicate.idMap[id] 336 | if idData then 337 | idData.networkOwner = other 338 | local latest = idData.snapshot:GetLatest() 339 | idData.snapshot:Clear() 340 | if latest then 341 | idData.snapshot:Push(0, latest.value) 342 | end 343 | ClientReplicate.BufferTracker.Clear(id) 344 | end 345 | elseif t == "m" then 346 | local model = Cache[id] 347 | if not model then 348 | local start = os.clock() 349 | repeat 350 | task.wait() 351 | model = Cache[id] 352 | until model or os.clock() - start > 5 353 | end 354 | if model and model:IsA("Model") and typeof(other) == "CFrame" then 355 | model:PivotTo(other) 356 | end 357 | local idData = ClientReplicate.idMap[id] 358 | if not idData then 359 | ClientOwners[id] = other 360 | return 361 | end 362 | idData.snapshot:Push(os.clock(), other) 363 | end 364 | end) 365 | NpcRegistryRemote:FireServer() 366 | end) 367 | else 368 | RunService.PostSimulation:Connect(function(dt) 369 | local now = os.clock() 370 | debug.profilebegin("Check Npc CFrames") 371 | for id, model in AutomaticNpc do 372 | local data = ServerReplicate.idMap[id] 373 | if not data or data.networkOwner then 374 | continue 375 | end 376 | local primary = model.PrimaryPart 377 | if not primary then 378 | continue 379 | end 380 | local cf = primary.CFrame 381 | ServerReplicate.PushNPCTransform(id, cf, now) 382 | end 383 | debug.profileend() 384 | end) 385 | Players.PlayerAdded:Connect(function(player) 386 | NpcRegistryRemote.OnServerEvent:Wait() 387 | 388 | table.insert(ServerReplicate._newPlayers, player) -- Allow the replicate module to setup the player 389 | -- for i,playerO in ClientOwners do 390 | -- local ids = {} 391 | -- for id in playerO do 392 | -- table.insert(ids, id) 393 | -- end 394 | -- NpcRegistryRemote:FireClient(player :: Player,"o" ,playerO,ids) 395 | -- end 396 | if not ClientOwners[player] then 397 | return 398 | end 399 | 400 | local npcS = {} 401 | for id in ClientOwners[player] do 402 | local data = ServerReplicate.idMap[id] 403 | npcS[tostring(id)] = true :: any 404 | if not data then 405 | continue 406 | end 407 | local latest = data.snapshot:GetLatest() 408 | if not latest then 409 | continue 410 | end 411 | npcS[tostring(id)] = latest.value 412 | end 413 | 414 | NpcRegistryRemote:FireClient(player :: Player, "i", npcS) 415 | end) 416 | end 417 | 418 | Npc._AutomaticNpc = AutomaticNpc 419 | Npc._ClientOwners = ClientOwners 420 | 421 | Npc.NpcAdded = NpcAdded.Event 422 | Npc.NpcRemoved = NpcRemoved.Event 423 | 424 | return Npc 425 | -------------------------------------------------------------------------------- /src/Events/Client.luau: -------------------------------------------------------------------------------- 1 | --!strict 2 | --!native 3 | --!optimize 2 4 | --!nolint LocalShadow 5 | --#selene: allow(shadowing) 6 | -- File generated by Blink v0.18.0 (https://github.com/1Axen/Blink) 7 | -- This file is not meant to be edited 8 | 9 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 10 | local RunService = game:GetService("RunService") 11 | 12 | local BASE_EVENT_NAME = "CHRONO_BLINK" 13 | local Invocations = 0 14 | 15 | local SendSize = 64 16 | local SendOffset = 0 17 | local SendCursor = 0 18 | local SendBuffer = buffer.create(64) 19 | local SendInstances = {} 20 | 21 | local RecieveCursor = 0 22 | local RecieveBuffer = buffer.create(64) 23 | 24 | local RecieveInstances = {} 25 | local RecieveInstanceCursor = 0 26 | 27 | local Null = newproxy() 28 | 29 | type Entry = { 30 | value: any, 31 | next: Entry? 32 | } 33 | 34 | type Queue = { 35 | head: Entry?, 36 | tail: Entry? 37 | } 38 | 39 | type BufferSave = { 40 | Size: number, 41 | Cursor: number, 42 | Buffer: buffer, 43 | Instances: {Instance} 44 | } 45 | 46 | local function Read(Bytes: number) 47 | local Offset = RecieveCursor 48 | RecieveCursor += Bytes 49 | return Offset 50 | end 51 | 52 | local function Save(): BufferSave 53 | return { 54 | Size = SendSize, 55 | Cursor = SendCursor, 56 | Buffer = SendBuffer, 57 | Instances = SendInstances 58 | } 59 | end 60 | 61 | local function Load(Save: BufferSave?) 62 | if Save then 63 | SendSize = Save.Size 64 | SendCursor = Save.Cursor 65 | SendOffset = Save.Cursor 66 | SendBuffer = Save.Buffer 67 | SendInstances = Save.Instances 68 | return 69 | end 70 | 71 | SendSize = 64 72 | SendCursor = 0 73 | SendOffset = 0 74 | SendBuffer = buffer.create(64) 75 | SendInstances = {} 76 | end 77 | 78 | local function Invoke() 79 | if Invocations == 255 then 80 | Invocations = 0 81 | end 82 | 83 | local Invocation = Invocations 84 | Invocations += 1 85 | return Invocation 86 | end 87 | 88 | local function Allocate(Bytes: number) 89 | local InUse = (SendCursor + Bytes) 90 | if InUse > SendSize then 91 | --> Avoid resizing the buffer for every write 92 | while InUse > SendSize do 93 | SendSize *= 1.5 94 | end 95 | 96 | local Buffer = buffer.create(SendSize) 97 | buffer.copy(Buffer, 0, SendBuffer, 0, SendCursor) 98 | SendBuffer = Buffer 99 | end 100 | 101 | SendOffset = SendCursor 102 | SendCursor += Bytes 103 | 104 | return SendOffset 105 | end 106 | 107 | local function CreateQueue(): Queue 108 | return { 109 | head = nil, 110 | tail = nil 111 | } 112 | end 113 | 114 | local function Pop(queue: Queue): any 115 | local head = queue.head 116 | if head == nil then 117 | return 118 | end 119 | 120 | queue.head = head.next 121 | return head.value 122 | end 123 | 124 | local function Push(queue: Queue, value: any) 125 | local entry: Entry = { 126 | value = value, 127 | next = nil 128 | } 129 | 130 | if queue.tail ~= nil then 131 | queue.tail.next = entry 132 | end 133 | 134 | queue.tail = entry 135 | 136 | if queue.head == nil then 137 | queue.head = entry 138 | end 139 | end 140 | 141 | local Calls = table.create(256) 142 | 143 | local Events: any = { 144 | Reliable = table.create(256), 145 | Unreliable = table.create(256) 146 | } 147 | 148 | local Queue: any = { 149 | Reliable = table.create(256), 150 | Unreliable = table.create(256) 151 | } 152 | 153 | Queue.Unreliable[1] = table.create(256) 154 | Queue.Reliable[0] = table.create(256) 155 | Queue.Reliable[1] = table.create(256) 156 | Events.Reliable[2] = {} 157 | Queue.Reliable[2] = table.create(256) 158 | Events.Reliable[3] = {} 159 | Queue.Reliable[3] = table.create(256) 160 | export type Transform = { Position: Vector3, RotationY: number } 161 | local function WriteEVENT_ClientReplicateCFrame(Value: { timestamp: number, cframe: { Position: Vector3, RotationY: number } }): () 162 | -- Allocate BLOCK: 19 bytes 163 | local BLOCK_START = Allocate(19) 164 | buffer.writeu8(SendBuffer, BLOCK_START + 0, 0) 165 | buffer.writef32(SendBuffer, BLOCK_START + 1, Value.timestamp) 166 | local Vector = Value.cframe.Position 167 | buffer.writef32(SendBuffer, BLOCK_START + 5, Vector.X) 168 | buffer.writef32(SendBuffer, BLOCK_START + 9, Vector.Y) 169 | buffer.writef32(SendBuffer, BLOCK_START + 13, Vector.Z) 170 | if Value.cframe.RotationY > 65504 then 171 | buffer.writeu16(SendBuffer, BLOCK_START + 17, 0b0_11111_0000000000) 172 | elseif Value.cframe.RotationY < -65504 then 173 | buffer.writeu16(SendBuffer, BLOCK_START + 17, 0b1_11111_0000000000) 174 | elseif Value.cframe.RotationY ~= Value.cframe.RotationY then 175 | buffer.writeu16(SendBuffer, BLOCK_START + 17, 0b1_11111_0000000001) 176 | elseif Value.cframe.RotationY == 0 then 177 | buffer.writeu16(SendBuffer, BLOCK_START + 17, 0) 178 | else 179 | local float = Value.cframe.RotationY 180 | local Abosulte = math.abs(float) 181 | local Interval = math.ldexp(1, math.floor(math.log(Abosulte, 2)) - 10) 182 | local RoundedValue = (Abosulte // Interval) * Interval 183 | local Fraction, Exponent = math.frexp(RoundedValue) 184 | Exponent += 14 185 | local Mantissa = math.round(if Exponent <= 0 186 | then Fraction * 0x400 / math.ldexp(1, math.abs(Exponent)) 187 | else Fraction * 0x800) % 0x400 188 | local Result = Mantissa 189 | + math.max(Exponent, 0) * 0x400 190 | + if float < 0 then 0x8000 else 0 191 | buffer.writeu16(SendBuffer, BLOCK_START + 17, Result) 192 | end 193 | end 194 | 195 | local function ReadEVENT_ServerReplicateCFrame(): ({ timestamps: {[number]: number}, cframes: {[number]: { Position: Vector3, RotationY: number }} }) 196 | -- Read BLOCK: 4 bytes 197 | local BLOCK_START = Read(4) 198 | local Value = {} :: any 199 | Value.timestamps = {} 200 | local Elements_1 = buffer.readu16(RecieveBuffer, BLOCK_START + 0) 201 | for _ = 1, Elements_1 do 202 | local OFFSET_0 = Read(2) 203 | local Key_1 = buffer.readu16(RecieveBuffer, OFFSET_0) 204 | local OFFSET_1 = Read(4) 205 | local Element_1 = buffer.readf32(RecieveBuffer, OFFSET_1) 206 | Value.timestamps[Key_1] = Element_1 207 | end 208 | Value.cframes = {} 209 | Elements_1 = buffer.readu16(RecieveBuffer, BLOCK_START + 2) 210 | for _ = 1, Elements_1 do 211 | local OFFSET_0 = Read(2) 212 | local Key_1 = buffer.readu16(RecieveBuffer, OFFSET_0) 213 | local Element_1 = {} :: any 214 | local OFFSET_1 = Read(4) 215 | local X = buffer.readf32(RecieveBuffer, OFFSET_1) 216 | local OFFSET_2 = Read(4) 217 | local Y = buffer.readf32(RecieveBuffer, OFFSET_2) 218 | local OFFSET_3 = Read(4) 219 | local Z = buffer.readf32(RecieveBuffer, OFFSET_3) 220 | Element_1.Position = Vector3.new(X, Y, Z) 221 | local OFFSET_4 = Read(2) 222 | local Encoded = buffer.readu16(RecieveBuffer, OFFSET_4) 223 | local MantissaExponent = Encoded % 0x8000 224 | if MantissaExponent == 0b0_11111_0000000000 then 225 | if Encoded // 0x8000 == 1 then 226 | Element_1.RotationY = -math.huge 227 | else 228 | Element_1.RotationY = math.huge 229 | end 230 | elseif MantissaExponent == 0b1_11111_0000000000 then 231 | Element_1.RotationY = 0 / 0 232 | elseif MantissaExponent == 0b0_00000_0000000000 then 233 | Element_1.RotationY = 0 234 | else 235 | local Mantissa = MantissaExponent % 0x400 236 | local Exponent = MantissaExponent // 0x400 237 | local Fraction; 238 | if Exponent == 0 then 239 | Fraction = Mantissa / 0x400 240 | else 241 | Fraction = Mantissa / 0x800 + 0.5 242 | end 243 | local Result = math.ldexp(Fraction, Exponent - 14) 244 | Element_1.RotationY = if Encoded // 0x8000 == 1 then -Result else Result 245 | end 246 | Value.cframes[Key_1] = Element_1 247 | end 248 | return Value 249 | end 250 | 251 | local function ReadEVENT_TickRateChanged(): ({ id: number, tickRate: number }) 252 | -- Read BLOCK: 6 bytes 253 | local BLOCK_START = Read(6) 254 | local Value = {} :: any 255 | Value.id = buffer.readu16(RecieveBuffer, BLOCK_START + 0) 256 | Value.tickRate = buffer.readf32(RecieveBuffer, BLOCK_START + 2) 257 | return Value 258 | end 259 | 260 | local function ReadEVENT_TogglePlayerReplication(): ({ id: number, on: boolean }) 261 | -- Read BLOCK: 3 bytes 262 | local BLOCK_START = Read(3) 263 | local Value = {} :: any 264 | Value.id = buffer.readu16(RecieveBuffer, BLOCK_START + 0) 265 | Value.on = (buffer.readu8(RecieveBuffer, BLOCK_START + 2) == 1) 266 | return Value 267 | end 268 | 269 | local function ReadEVENT_InitializePlayer(): ({ id: number, player: string }) 270 | -- Read BLOCK: 4 bytes 271 | local BLOCK_START = Read(4) 272 | local Value = {} :: any 273 | Value.id = buffer.readu16(RecieveBuffer, BLOCK_START + 0) 274 | local Length = buffer.readu16(RecieveBuffer, BLOCK_START + 2) 275 | Value.player = buffer.readstring(RecieveBuffer, Read(Length), Length) 276 | return Value 277 | end 278 | 279 | local function ReadEVENT_InitializeExistingPlayers(): ({ { id: number, player: string } }) 280 | -- Read BLOCK: 2 bytes 281 | local BLOCK_START = Read(2) 282 | -- START ARRAY 283 | local Length = buffer.readu16(RecieveBuffer, BLOCK_START + 0) 284 | if Length < 0 then error(`Expected "Length" to be larger than 0, got {Length} instead.`) end 285 | if Length > 65535 then error(`Expected "Length" to be smaller than 65535, got {Length} instead.`) end 286 | local Value = table.create(Length) 287 | -- Read BLOCK: 4 bytes 288 | local ARRAY_START_1 = Read(4 * Length) 289 | for Index = 1, Length do 290 | local Item_1 = {} :: any 291 | -- Read 2 292 | local OPERATION_OFFSET_0 = ARRAY_START_1 293 | ARRAY_START_1 += 2 294 | Item_1.id = buffer.readu16(RecieveBuffer, OPERATION_OFFSET_0) 295 | -- Read 2 296 | local OPERATION_OFFSET_1 = ARRAY_START_1 297 | ARRAY_START_1 += 2 298 | Length = buffer.readu16(RecieveBuffer, OPERATION_OFFSET_1) 299 | Item_1.player = buffer.readstring(RecieveBuffer, Read(Length), Length) 300 | table.insert(Value, Item_1) 301 | end 302 | -- END ARRAY 303 | return Value 304 | end 305 | 306 | if not RunService:IsRunning() then 307 | local NOOP = function() end 308 | local Returns = table.freeze({ 309 | ClientReplicateCFrame = { 310 | Fire = NOOP 311 | }, 312 | ServerReplicateCFrame = { 313 | On = NOOP 314 | }, 315 | TickRateChanged = { 316 | On = NOOP 317 | }, 318 | TogglePlayerReplication = { 319 | On = NOOP 320 | }, 321 | InitializePlayer = { 322 | On = NOOP 323 | }, 324 | InitializeExistingPlayers = { 325 | On = NOOP 326 | }, 327 | }) 328 | return Returns :: BLINK_EVENTS_SYMBOL 329 | end 330 | 331 | if not RunService:IsClient() then 332 | error("Client network module can only be required from the client.") 333 | end 334 | 335 | local Reliable: RemoteEvent = ReplicatedStorage:WaitForChild(BASE_EVENT_NAME .. "_RELIABLE_REMOTE") :: RemoteEvent 336 | local Unreliable: UnreliableRemoteEvent = ReplicatedStorage:WaitForChild(BASE_EVENT_NAME .. "_UNRELIABLE_REMOTE") :: UnreliableRemoteEvent 337 | 338 | local function StepReplication() 339 | if SendCursor <= 0 then 340 | return 341 | end 342 | 343 | local Buffer = buffer.create(SendCursor) 344 | buffer.copy(Buffer, 0, SendBuffer, 0, SendCursor) 345 | Reliable:FireServer(Buffer, SendInstances) 346 | 347 | SendSize = 64 348 | SendCursor = 0 349 | SendOffset = 0 350 | SendBuffer = buffer.create(64) 351 | table.clear(SendInstances) 352 | end 353 | RunService.Heartbeat:Connect(StepReplication) 354 | Reliable.OnClientEvent:Connect(function(Buffer: buffer, Instances: {Instance}) 355 | RecieveCursor = 0 356 | RecieveBuffer = Buffer 357 | RecieveInstances = Instances 358 | RecieveInstanceCursor = 0 359 | local Size = buffer.len(RecieveBuffer) 360 | while (RecieveCursor < Size) do 361 | -- Read BLOCK: 1 bytes 362 | local BLOCK_START = Read(1) 363 | local Index = buffer.readu8(RecieveBuffer, BLOCK_START + 0) 364 | if Index == 0 then 365 | local Value: { id: number, tickRate: number } = ReadEVENT_TickRateChanged() 366 | if Events.Reliable[0] ~= nil then 367 | Events.Reliable[0](Value) 368 | else 369 | if #Queue.Reliable[0] > 256 then 370 | warn("[Blink]: Event queue of \"TickRateChanged\" exceeded 256, did you forget to implement a listener?") 371 | end 372 | table.insert(Queue.Reliable[0], {Value} :: {any}) 373 | end 374 | elseif Index == 1 then 375 | local Value: { id: number, on: boolean } = ReadEVENT_TogglePlayerReplication() 376 | if Events.Reliable[1] ~= nil then 377 | Events.Reliable[1](Value) 378 | else 379 | if #Queue.Reliable[1] > 256 then 380 | warn("[Blink]: Event queue of \"TogglePlayerReplication\" exceeded 256, did you forget to implement a listener?") 381 | end 382 | table.insert(Queue.Reliable[1], {Value} :: {any}) 383 | end 384 | elseif Index == 2 then 385 | local Value: { id: number, player: string } = ReadEVENT_InitializePlayer() 386 | if #Events.Reliable[2] > 0 then 387 | for _, Listener in Events.Reliable[2] do 388 | task.spawn(Listener, Value) 389 | end 390 | else 391 | if #Queue.Reliable[2] > 256 then 392 | warn("[Blink]: Event queue of \"InitializePlayer\" exceeded 256, did you forget to implement a listener?") 393 | end 394 | table.insert(Queue.Reliable[2], {Value} :: {any}) 395 | end 396 | elseif Index == 3 then 397 | local Value: { { id: number, player: string } } = ReadEVENT_InitializeExistingPlayers() 398 | if #Events.Reliable[3] > 0 then 399 | for _, Listener in Events.Reliable[3] do 400 | task.spawn(Listener, Value) 401 | end 402 | else 403 | if #Queue.Reliable[3] > 256 then 404 | warn("[Blink]: Event queue of \"InitializeExistingPlayers\" exceeded 256, did you forget to implement a listener?") 405 | end 406 | table.insert(Queue.Reliable[3], {Value} :: {any}) 407 | end 408 | end 409 | end 410 | end) 411 | Unreliable.OnClientEvent:Connect(function(Buffer: buffer, Instances: {Instance}) 412 | RecieveCursor = 0 413 | RecieveBuffer = Buffer 414 | RecieveInstances = Instances 415 | RecieveInstanceCursor = 0 416 | local Size = buffer.len(RecieveBuffer) 417 | while (RecieveCursor < Size) do 418 | -- Read BLOCK: 1 bytes 419 | local BLOCK_START = Read(1) 420 | local Index = buffer.readu8(RecieveBuffer, BLOCK_START + 0) 421 | if Index == 1 then 422 | local Value: { timestamps: {[number]: number}, cframes: {[number]: { Position: Vector3, RotationY: number }} } = ReadEVENT_ServerReplicateCFrame() 423 | if Events.Unreliable[1] ~= nil then 424 | Events.Unreliable[1](Value) 425 | end 426 | end 427 | end 428 | end) 429 | 430 | 431 | local Returns = table.freeze({ 432 | StepReplication = StepReplication, 433 | 434 | ClientReplicateCFrame = { 435 | Fire = function(Value: { timestamp: number, cframe: { Position: Vector3, RotationY: number } }): () 436 | local Previous = Save() 437 | Load() 438 | WriteEVENT_ClientReplicateCFrame(Value) 439 | local Buffer = buffer.create(SendCursor) 440 | buffer.copy(Buffer, 0, SendBuffer, 0, SendCursor) 441 | Unreliable:FireServer(Buffer, SendInstances) 442 | Load(Previous) 443 | end 444 | }, 445 | ServerReplicateCFrame = { 446 | On = function(Listener: (Value: { timestamps: {[number]: number}, cframes: {[number]: { Position: Vector3, RotationY: number }} }) -> ()): () -> () 447 | Events.Unreliable[1] = Listener 448 | return function (): () 449 | if Events.Unreliable[1] == Listener then 450 | Events.Unreliable[1] = nil 451 | end 452 | end 453 | end 454 | }, 455 | TickRateChanged = { 456 | On = function(Listener: (Value: { id: number, tickRate: number }) -> ()): () -> () 457 | Events.Reliable[0] = Listener 458 | for Index, Arguments in Queue.Reliable[0] do 459 | Listener(table.unpack(Arguments)) 460 | end 461 | Queue.Reliable[0] = {} 462 | return function (): () 463 | if Events.Reliable[0] == Listener then 464 | Events.Reliable[0] = nil 465 | end 466 | end 467 | end 468 | }, 469 | TogglePlayerReplication = { 470 | On = function(Listener: (Value: { id: number, on: boolean }) -> ()): () -> () 471 | Events.Reliable[1] = Listener 472 | for Index, Arguments in Queue.Reliable[1] do 473 | Listener(table.unpack(Arguments)) 474 | end 475 | Queue.Reliable[1] = {} 476 | return function (): () 477 | if Events.Reliable[1] == Listener then 478 | Events.Reliable[1] = nil 479 | end 480 | end 481 | end 482 | }, 483 | InitializePlayer = { 484 | On = function(Listener: (Value: { id: number, player: string }) -> ()): () -> () 485 | table.insert(Events.Reliable[2], Listener) 486 | for Index, Arguments in Queue.Reliable[2] do 487 | task.spawn(Listener, table.unpack(Arguments)) 488 | end 489 | Queue.Reliable[2] = {} 490 | return function (): () 491 | local Index = table.find(Events.Reliable[2], Listener) 492 | if Index ~= nil then 493 | table.remove(Events.Reliable[2], Index) 494 | end 495 | end 496 | end 497 | }, 498 | InitializeExistingPlayers = { 499 | On = function(Listener: (Value: { { id: number, player: string } }) -> ()): () -> () 500 | table.insert(Events.Reliable[3], Listener) 501 | for Index, Arguments in Queue.Reliable[3] do 502 | task.spawn(Listener, table.unpack(Arguments)) 503 | end 504 | Queue.Reliable[3] = {} 505 | return function (): () 506 | local Index = table.find(Events.Reliable[3], Listener) 507 | if Index ~= nil then 508 | table.remove(Events.Reliable[3], Index) 509 | end 510 | end 511 | end 512 | }, 513 | }) 514 | type BLINK_EVENTS_SYMBOL = typeof(Returns) 515 | return Returns :: BLINK_EVENTS_SYMBOL -------------------------------------------------------------------------------- /src/Client/Replicate.luau: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | local RunService = game:GetService("RunService") 4 | 5 | local Config = require(script.Parent.Parent.Shared.Config) 6 | local Snapshots = require(script.Parent.Parent.Shared.Snapshots) 7 | local Events = require(script.Parent.Parent.Events.Client) 8 | local InterpolationBuffer = require(script.Parent.InterpolationBuffer) 9 | local Networkables = require(script.Parent.Parent.Shared.MakeNetworkable) 10 | local RenderCache = require(script.Parent.RenderCache) 11 | local Rig = require(script.Parent.Parent.Shared.Rigs.CreateCharacter) 12 | local Character = require(script.Parent.Parent.Shared.Character) 13 | local InterpolationMath = require(script.Parent.Parent.Shared.InterpolationMath) 14 | 15 | local CUSTOM_CHARACTERS = Config.ENABLE_CUSTOM_CHARACTERS 16 | local MAX_UNRELIABLE_BYTES = 900 17 | local SNAPSHOT_SIZE = if Config.SEND_FULL_ROTATION then 24 else 20 18 | local HEADER_SIZE = 2 19 | local MAX_AWAITING_TIME = 2 --seconds 20 | local MAX_BATCH = (MAX_UNRELIABLE_BYTES - HEADER_SIZE) // SNAPSHOT_SIZE 21 | 22 | local outgoingSnapshots = {} :: { { timestamp: number, cframe: CFrame, id: number } } 23 | 24 | local ClientReplicateCFrame: UnreliableRemoteEvent = ReplicatedStorage:WaitForChild("ClientReplicateCFrame") :: any 25 | local ServerReplicateCFrame: UnreliableRemoteEvent = ReplicatedStorage:WaitForChild("ServerReplicateCFrame") :: any 26 | local DeathEvent: RemoteEvent = ReplicatedStorage:WaitForChild("DeathEvent") :: any 27 | 28 | local idMap = {} :: { 29 | [number]: { 30 | networkOwner: boolean?, 31 | snapshot: Snapshots.Snapshot, 32 | character: Model?, 33 | lastCFrame: CFrame?, 34 | lastSent: number?, 35 | 36 | isNPC: boolean?, 37 | npcType: string?, 38 | 39 | player: Player?, 40 | _characterAdded: RBXScriptConnection?, 41 | }, 42 | } 43 | 44 | local awaitingSnapshots = {} :: { [number]: { timestamp: number, cframe: CFrame, clock: number } } 45 | 46 | local clientOwnerShip: {} 47 | 48 | task.defer(function() 49 | local NpcRegistry = (require)(script.Parent.Parent.Shared.NpcRegistry) 50 | clientOwnerShip = NpcRegistry._ClientOwners 51 | end) 52 | 53 | local playerToId = {} :: { [Player]: number } 54 | local player = Players.LocalPlayer 55 | local characters = {} :: { [Player]: Model } 56 | local playerTickRates = {} :: { [number]: number } 57 | local pausedPlayers = {} :: { [number]: boolean } 58 | 59 | local bufferTracker = InterpolationBuffer(Config.MIN_BUFFER, Config.MAX_BUFFER, 0.1) 60 | RenderCache.Init({ 61 | playerTickRates = playerTickRates, 62 | bufferTracker = bufferTracker, 63 | }) 64 | 65 | local function GetCharacter(player: Player): Model? 66 | if CUSTOM_CHARACTERS then 67 | return Character.GetCharacter(player) 68 | else 69 | return player.Character 70 | end 71 | end 72 | 73 | local function WaitForCharacter(player: Player): Model 74 | if CUSTOM_CHARACTERS then 75 | local _player, character 76 | repeat 77 | _player, character = Character.CharacterAdded:Wait() 78 | until _player == player 79 | return character 80 | else 81 | return player.CharacterAdded:Wait() 82 | end 83 | end 84 | 85 | local playerNetworkId = 300 86 | 87 | local Hermite = InterpolationMath.Hermite 88 | local VelocityAt = InterpolationMath.VelocityAt 89 | 90 | local function UnpackSnapshotData(snapshotBuffer: buffer, offset: number): any 91 | local value = {} 92 | value.timestamp = buffer.readf32(snapshotBuffer, offset + 0) 93 | 94 | local x = buffer.readf32(snapshotBuffer, offset + 4) 95 | local y = buffer.readf32(snapshotBuffer, offset + 8) 96 | local z = buffer.readf32(snapshotBuffer, offset + 12) 97 | 98 | if Config.SEND_FULL_ROTATION then 99 | local mappedX = buffer.readu16(snapshotBuffer, offset + 16) 100 | local mappedY = buffer.readu16(snapshotBuffer, offset + 18) 101 | local mappedZ = buffer.readu16(snapshotBuffer, offset + 20) 102 | 103 | local rx = math.map(mappedX, 0, 2 ^ 16 - 1, -1, 1) 104 | local ry = math.map(mappedY, 0, 2 ^ 16 - 1, -1, 1) 105 | local rz = math.map(mappedZ, 0, 2 ^ 16 - 1, -1, 1) 106 | 107 | value.cframe = Networkables.DecodeCFrame({ 108 | Position = vector.create(x, y, z) :: any, 109 | Rotation = { x = rx, y = ry, z = rz }, 110 | }) 111 | value.id = buffer.readu16(snapshotBuffer, offset + 22) 112 | else 113 | local rotationY = buffer.readu16(snapshotBuffer, offset + 16) 114 | local remappedRotationY = math.map(rotationY, 0, 2 ^ 16 - 1, -math.pi, math.pi) 115 | value.cframe = Networkables.DecodeYawCFrame({ 116 | Position = vector.create(x, y, z) :: any, 117 | RotationY = remappedRotationY, 118 | }) 119 | value.id = buffer.readu16(snapshotBuffer, offset + 18) 120 | end 121 | 122 | return value 123 | end 124 | 125 | local function PackSnapshotData(snapshotBuffer: buffer, offset: number, timestamp: number, cframe: CFrame, id: number) 126 | buffer.writef32(snapshotBuffer, offset + 0, timestamp) 127 | buffer.writef32(snapshotBuffer, offset + 4, cframe.Position.X) 128 | buffer.writef32(snapshotBuffer, offset + 8, cframe.Position.Y) 129 | buffer.writef32(snapshotBuffer, offset + 12, cframe.Position.Z) 130 | 131 | if Config.SEND_FULL_ROTATION then 132 | local networkable = Networkables.MakeNetworkable(cframe) 133 | 134 | local mappedX = math.map(networkable.Rotation.x, -1, 1, 0, 2 ^ 16 - 1) 135 | local mappedY = math.map(networkable.Rotation.y, -1, 1, 0, 2 ^ 16 - 1) 136 | local mappedZ = math.map(networkable.Rotation.z, -1, 1, 0, 2 ^ 16 - 1) 137 | 138 | buffer.writeu16(snapshotBuffer, offset + 16, mappedX) 139 | buffer.writeu16(snapshotBuffer, offset + 18, mappedY) 140 | buffer.writeu16(snapshotBuffer, offset + 20, mappedZ) 141 | buffer.writeu16(snapshotBuffer, offset + 22, id) 142 | else 143 | local networkable = Networkables.MakeYawNetworkable(cframe) 144 | local mappedRotationY = math.map(networkable.RotationY, -math.pi, math.pi, 0, 2 ^ 16 - 1) 145 | buffer.writeu16(snapshotBuffer, offset + 16, mappedRotationY) 146 | buffer.writeu16(snapshotBuffer, offset + 18, id) 147 | end 148 | end 149 | 150 | local function Flush() 151 | local count = math.min(#outgoingSnapshots, MAX_BATCH) 152 | if count == 0 then 153 | return false 154 | end 155 | 156 | local offset = 0 157 | local snapshotBuffer = buffer.create(count * SNAPSHOT_SIZE) 158 | for i = 1, count do 159 | local snapshot = outgoingSnapshots[#outgoingSnapshots] 160 | outgoingSnapshots[#outgoingSnapshots] = nil 161 | PackSnapshotData(snapshotBuffer, offset, snapshot.timestamp, snapshot.cframe, snapshot.id) 162 | offset += SNAPSHOT_SIZE 163 | end 164 | 165 | ClientReplicateCFrame:FireServer(snapshotBuffer) 166 | Flush() 167 | 168 | return true 169 | end 170 | 171 | local function RegisterClientNPC(id: number, model: Model, npcType: string?) 172 | if not idMap[id] then 173 | npcType = npcType or "DEFAULT" 174 | idMap[id] = { 175 | snapshot = Snapshots(Hermite), 176 | character = nil, 177 | lastCFrame = nil, 178 | isNPC = true, 179 | npcType = npcType, 180 | } 181 | RenderCache.Add(id, true, npcType) 182 | end 183 | 184 | if clientOwnerShip[id] and clientOwnerShip[id] ~= true then 185 | local now = os.clock() 186 | local latest = idMap[id].snapshot:GetLatest() 187 | local velocity = VelocityAt(latest, now, clientOwnerShip[id]) 188 | idMap[id].snapshot:Push(now, clientOwnerShip[id], velocity) 189 | clientOwnerShip[id] = true 190 | end 191 | 192 | idMap[id].character = model 193 | 194 | if npcType then 195 | idMap[id].npcType = npcType 196 | end 197 | end 198 | 199 | local function UnregisterNPC(id: number): Model? 200 | local data = idMap[id] 201 | if not data then 202 | return nil 203 | end 204 | 205 | bufferTracker.Remove(id) 206 | 207 | idMap[id] = nil 208 | return data.character 209 | end 210 | 211 | local function NewCharacter(idData: typeof(idMap[1])) 212 | local player = idData.player 213 | if not player then 214 | return 215 | end 216 | 217 | local initializedCharacter = GetCharacter(player) or WaitForCharacter(player) 218 | if not initializedCharacter.Parent then 219 | initializedCharacter = WaitForCharacter(player) 220 | end 221 | 222 | local old = characters[player] 223 | if old and old ~= initializedCharacter then 224 | pcall(old.Destroy, old) 225 | end 226 | 227 | local primaryPart = initializedCharacter.PrimaryPart :: BasePart 228 | 229 | task.spawn(function() 230 | if not Config.DISABLE_DEFAULT_REPLICATION then 231 | return 232 | end 233 | if not primaryPart then 234 | task.wait(1) -- for some reason primary part didn't exist immediately? 235 | primaryPart = initializedCharacter.PrimaryPart :: BasePart 236 | end 237 | if not primaryPart then 238 | warn("Player has no primary part", player, initializedCharacter) 239 | return 240 | end 241 | primaryPart.Anchored = false 242 | 243 | primaryPart:GetPropertyChangedSignal("Anchored"):Connect(function() 244 | primaryPart.Anchored = false 245 | end) 246 | 247 | local humanoid = initializedCharacter:FindFirstChild("Humanoid") :: Humanoid 248 | if not humanoid then 249 | return 250 | end 251 | 252 | humanoid.Died:Connect(function() 253 | DeathEvent:FireServer() 254 | end) 255 | end) 256 | 257 | characters[player] = initializedCharacter 258 | 259 | local initialCFrame = CFrame.identity 260 | 261 | if characters[player] and characters[player].PrimaryPart then 262 | initialCFrame = (characters[player] :: any).PrimaryPart.CFrame 263 | end 264 | 265 | idData.character = initializedCharacter 266 | idData.lastCFrame = initialCFrame 267 | end 268 | 269 | local function PlayerAdded(player: Player, id: number) 270 | local registeredSnapshots = Snapshots(Hermite) 271 | playerToId[player] = id 272 | idMap[id] = { 273 | player = player, 274 | snapshot = registeredSnapshots, 275 | character = nil, 276 | lastCFrame = CFrame.identity, 277 | } 278 | 279 | RenderCache.Add(id) 280 | 281 | if not CUSTOM_CHARACTERS then 282 | idMap[id]._characterAdded = player.CharacterAdded:Connect(function(char) 283 | NewCharacter(idMap[id]) 284 | end) 285 | end 286 | 287 | NewCharacter(idMap[id]) 288 | end 289 | 290 | Events.InitializePlayer.On(function(data) 291 | local playerInstance = Players[data.player] 292 | 293 | if playerInstance then 294 | PlayerAdded(playerInstance, data.id) 295 | end 296 | end) 297 | 298 | Events.InitializeExistingPlayers.On(function(data) 299 | for _, playerData in data do 300 | local playerInstance = Players[playerData.player] 301 | 302 | if playerInstance then 303 | PlayerAdded(playerInstance, playerData.id) 304 | end 305 | end 306 | end) 307 | 308 | local lastSent = os.clock() 309 | 310 | Events.TickRateChanged.On(function(data) 311 | playerTickRates[data.id] = data.tickRate 312 | end) 313 | 314 | Events.TogglePlayerReplication.On(function(data) 315 | if data.on then 316 | pausedPlayers[data.id] = nil 317 | else 318 | pausedPlayers[data.id] = true 319 | end 320 | end) 321 | 322 | local function HandleReplicatedData(clientLastTicks, cframes) 323 | for id, serverTime in clientLastTicks do 324 | if idMap[id] and not idMap[id].isNPC then 325 | bufferTracker.RegisterPacket(id, serverTime, playerTickRates[id] or Config.TICK_RATE) 326 | end 327 | end 328 | 329 | RenderCache.OnSnapshotUpdate(clientLastTicks) 330 | 331 | for id, cframe in cframes do 332 | local entry = idMap[id] 333 | local targetTime = RenderCache.GetTargetRenderTime(id) 334 | if not entry then 335 | continue 336 | elseif entry.isNPC and entry.npcType and not targetTime then 337 | RenderCache.Add(id, true, entry.npcType) 338 | end 339 | 340 | local latest = entry.snapshot:GetLatest() 341 | if latest and clientLastTicks[id] - latest.t < 5 and targetTime then 342 | if math.abs(targetTime - latest.t) > 5 then 343 | entry.snapshot:Clear() 344 | warn(`Clearing Snapshot due to large time difference during ownership changes`) 345 | end 346 | end 347 | --cframes are already decoded when unpacking, so we push directly 348 | entry.snapshot:Push(clientLastTicks[id], cframe, VelocityAt(latest, clientLastTicks[id], cframe)) 349 | end 350 | end 351 | 352 | ServerReplicateCFrame.OnClientEvent:Connect(function(snapshotBuffer) 353 | local cframes = {} 354 | local timestamps = {} 355 | local count, offset = buffer.len(snapshotBuffer) // SNAPSHOT_SIZE, 0 356 | 357 | for i = 1, count do 358 | local snapshot = UnpackSnapshotData(snapshotBuffer, offset) 359 | offset += SNAPSHOT_SIZE 360 | 361 | local id = snapshot.id 362 | if not idMap[id] then 363 | if awaitingSnapshots[id] and awaitingSnapshots[id].timestamp > snapshot.timestamp then 364 | continue 365 | end 366 | awaitingSnapshots[id] = { timestamp = snapshot.timestamp, cframe = snapshot.cframe, clock = os.clock() } 367 | continue 368 | end 369 | cframes[id] = snapshot.cframe 370 | timestamps[id] = snapshot.timestamp 371 | end 372 | 373 | HandleReplicatedData(timestamps, cframes) 374 | end) 375 | 376 | RunService.PreRender:Connect(function(deltaTime: number) 377 | RenderCache.Update(deltaTime) 378 | debug.profilebegin("Calculate CFrames") 379 | for id, data in idMap do 380 | if not data.character or data.character == (GetCharacter(player)) then 381 | continue 382 | end 383 | 384 | if pausedPlayers[id] then 385 | continue 386 | end 387 | 388 | local primaryPart = data.character.PrimaryPart 389 | if not primaryPart then 390 | continue 391 | end 392 | 393 | debug.profilebegin("Get Target CFrame") 394 | local targetRenderTime = RenderCache.GetTargetRenderTime(id) 395 | local targetCFrame = data.snapshot:GetAt(targetRenderTime) 396 | debug.profileend() 397 | 398 | if clientOwnerShip[id] and (data :: any).initializedCFrame then 399 | continue 400 | end 401 | if clientOwnerShip[id] then 402 | local latest = data.snapshot:GetLatest() 403 | if not latest then 404 | continue 405 | end 406 | 407 | targetCFrame = latest.value; 408 | (data :: any).initializedCFrame = true 409 | end 410 | 411 | debug.profilebegin("Prepare CFrame") 412 | if targetCFrame then 413 | --data.lastCFrame = targetCFrame 414 | primaryPart.CFrame = targetCFrame 415 | end 416 | debug.profileend() 417 | end 418 | debug.profileend() 419 | end) 420 | 421 | local function CheckCFrameChanges(cframe: CFrame, last: CFrame) 422 | local changed = (last.Position - cframe.Position).Magnitude >= 0.05 423 | or not last.Rotation:FuzzyEq(cframe.Rotation, 0.0001) 424 | return changed 425 | end 426 | 427 | local lastSentCFrame = CFrame.identity 428 | 429 | local function HandleNpcs() 430 | local now = os.clock() 431 | local npcConfigs = Config.NPC_TYPES 432 | 433 | for i: any in clientOwnerShip do 434 | local data = idMap[i] 435 | --The client has to atleast received one snapshot before we start sending updates 436 | if not data or not data.snapshot:GetLatest() then 437 | continue 438 | end 439 | 440 | local tickRate = npcConfigs[data.npcType or "DEFAULT"].TICK_RATE 441 | if now - (data.lastSent or 0) < tickRate then 442 | continue 443 | end 444 | 445 | data.lastSent = now 446 | if not data.character then 447 | continue 448 | end 449 | 450 | local character = data.character 451 | local primaryPart = character.PrimaryPart 452 | if not primaryPart then 453 | continue 454 | end 455 | 456 | local currentCFrame = primaryPart.CFrame 457 | local changed = CheckCFrameChanges(currentCFrame, data.lastCFrame or CFrame.identity) 458 | if not changed then 459 | continue 460 | end 461 | 462 | data.lastCFrame = currentCFrame 463 | table.insert(outgoingSnapshots, { 464 | timestamp = os.clock(), 465 | cframe = currentCFrame, 466 | id = i + 1, 467 | }) 468 | end 469 | end 470 | 471 | local function HandleCharacter() 472 | if os.clock() - lastSent < (playerTickRates[playerNetworkId] or Config.TICK_RATE) then 473 | return 474 | end 475 | 476 | lastSent = os.clock() 477 | 478 | local character = GetCharacter(player) 479 | if not character then 480 | return 481 | end 482 | 483 | local primaryPart = character.PrimaryPart 484 | if not primaryPart then 485 | return 486 | end 487 | 488 | local currentCFrame = primaryPart.CFrame 489 | 490 | local changed = CheckCFrameChanges(currentCFrame, lastSentCFrame) 491 | 492 | lastSentCFrame = currentCFrame 493 | 494 | if not changed then 495 | return 496 | end 497 | 498 | table.insert(outgoingSnapshots, { 499 | timestamp = os.clock(), 500 | cframe = currentCFrame, 501 | id = 0, 502 | }) 503 | end 504 | 505 | local function HandleAwaitingSnapshots() 506 | local now = os.clock() 507 | for id, snapshot in awaitingSnapshots do 508 | if now - snapshot.clock < MAX_AWAITING_TIME then 509 | if idMap[id] then 510 | HandleReplicatedData({ [id] = snapshot.timestamp }, { [id] = snapshot.cframe }) 511 | awaitingSnapshots[id] = nil 512 | end 513 | else 514 | awaitingSnapshots[id] = nil 515 | end 516 | end 517 | end 518 | 519 | RunService.PostSimulation:Connect(function() 520 | HandleAwaitingSnapshots() 521 | HandleCharacter() 522 | HandleNpcs() 523 | Flush() 524 | end) 525 | 526 | Players.PlayerRemoving:Connect(function(player) 527 | local idToRemove = playerToId[player] 528 | playerToId[player] = nil 529 | 530 | if idToRemove then 531 | RenderCache.Remove(idToRemove) 532 | local data = idMap[idToRemove] 533 | if data and data._characterAdded then 534 | data._characterAdded:Disconnect() 535 | end 536 | idMap[idToRemove] = nil 537 | end 538 | 539 | local character = characters[player] 540 | characters[player] = nil 541 | 542 | if character then 543 | pcall(character.Destroy, character) 544 | end 545 | end) 546 | 547 | Character.CharacterAdded:Connect(function(player, character) 548 | local id = playerToId[player] 549 | if not id then 550 | return 551 | end 552 | local data = idMap[id] 553 | if not data then 554 | return 555 | end 556 | if data.character then 557 | character:PivotTo(data.character:GetPivot()) 558 | end 559 | data.character = character 560 | characters[player] = character 561 | end) 562 | 563 | return { 564 | idMap = idMap, 565 | playerTickRates = playerTickRates, 566 | BufferTracker = bufferTracker, 567 | 568 | RegisterClientNPC = RegisterClientNPC, 569 | UnregisterNPC = UnregisterNPC, 570 | 571 | GetAllNetworkIds = function() 572 | local ids = {} 573 | for id, _ in idMap do 574 | table.insert(ids, id) 575 | end 576 | return ids 577 | end, 578 | } 579 | -------------------------------------------------------------------------------- /src/Events/Server.luau: -------------------------------------------------------------------------------- 1 | --!strict 2 | --!native 3 | --!optimize 2 4 | --!nolint LocalShadow 5 | --#selene: allow(shadowing) 6 | -- File generated by Blink v0.18.0 (https://github.com/1Axen/Blink) 7 | -- This file is not meant to be edited 8 | 9 | local Players = game:GetService("Players") 10 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 11 | local RunService = game:GetService("RunService") 12 | 13 | local BASE_EVENT_NAME = "CHRONO_BLINK" 14 | local Invocations = 0 15 | 16 | local SendSize = 64 17 | local SendOffset = 0 18 | local SendCursor = 0 19 | local SendBuffer = buffer.create(64) 20 | local SendInstances = {} 21 | 22 | local RecieveCursor = 0 23 | local RecieveBuffer = buffer.create(64) 24 | 25 | local RecieveInstances = {} 26 | local RecieveInstanceCursor = 0 27 | 28 | local Null = newproxy() 29 | 30 | type Entry = { 31 | value: any, 32 | next: Entry? 33 | } 34 | 35 | type Queue = { 36 | head: Entry?, 37 | tail: Entry? 38 | } 39 | 40 | type BufferSave = { 41 | Size: number, 42 | Cursor: number, 43 | Buffer: buffer, 44 | Instances: {Instance} 45 | } 46 | 47 | local function Read(Bytes: number) 48 | local Offset = RecieveCursor 49 | RecieveCursor += Bytes 50 | return Offset 51 | end 52 | 53 | local function Save(): BufferSave 54 | return { 55 | Size = SendSize, 56 | Cursor = SendCursor, 57 | Buffer = SendBuffer, 58 | Instances = SendInstances 59 | } 60 | end 61 | 62 | local function Load(Save: BufferSave?) 63 | if Save then 64 | SendSize = Save.Size 65 | SendCursor = Save.Cursor 66 | SendOffset = Save.Cursor 67 | SendBuffer = Save.Buffer 68 | SendInstances = Save.Instances 69 | return 70 | end 71 | 72 | SendSize = 64 73 | SendCursor = 0 74 | SendOffset = 0 75 | SendBuffer = buffer.create(64) 76 | SendInstances = {} 77 | end 78 | 79 | local function Invoke() 80 | if Invocations == 255 then 81 | Invocations = 0 82 | end 83 | 84 | local Invocation = Invocations 85 | Invocations += 1 86 | return Invocation 87 | end 88 | 89 | local function Allocate(Bytes: number) 90 | local InUse = (SendCursor + Bytes) 91 | if InUse > SendSize then 92 | --> Avoid resizing the buffer for every write 93 | while InUse > SendSize do 94 | SendSize *= 1.5 95 | end 96 | 97 | local Buffer = buffer.create(SendSize) 98 | buffer.copy(Buffer, 0, SendBuffer, 0, SendCursor) 99 | SendBuffer = Buffer 100 | end 101 | 102 | SendOffset = SendCursor 103 | SendCursor += Bytes 104 | 105 | return SendOffset 106 | end 107 | 108 | local function CreateQueue(): Queue 109 | return { 110 | head = nil, 111 | tail = nil 112 | } 113 | end 114 | 115 | local function Pop(queue: Queue): any 116 | local head = queue.head 117 | if head == nil then 118 | return 119 | end 120 | 121 | queue.head = head.next 122 | return head.value 123 | end 124 | 125 | local function Push(queue: Queue, value: any) 126 | local entry: Entry = { 127 | value = value, 128 | next = nil 129 | } 130 | 131 | if queue.tail ~= nil then 132 | queue.tail.next = entry 133 | end 134 | 135 | queue.tail = entry 136 | 137 | if queue.head == nil then 138 | queue.head = entry 139 | end 140 | end 141 | 142 | local Calls = table.create(256) 143 | 144 | local Events: any = { 145 | Reliable = table.create(256), 146 | Unreliable = table.create(256) 147 | } 148 | 149 | local Queue: any = { 150 | Reliable = table.create(256), 151 | Unreliable = table.create(256) 152 | } 153 | 154 | Queue.Unreliable[0] = table.create(256) 155 | export type Transform = { Position: Vector3, RotationY: number } 156 | local function ReadEVENT_ClientReplicateCFrame(): ({ timestamp: number, cframe: { Position: Vector3, RotationY: number } }) 157 | -- Read BLOCK: 18 bytes 158 | local BLOCK_START = Read(18) 159 | local Value = {} :: any 160 | Value.timestamp = buffer.readf32(RecieveBuffer, BLOCK_START + 0) 161 | Value.cframe = {} :: any 162 | local X = buffer.readf32(RecieveBuffer, BLOCK_START + 4) 163 | local Y = buffer.readf32(RecieveBuffer, BLOCK_START + 8) 164 | local Z = buffer.readf32(RecieveBuffer, BLOCK_START + 12) 165 | Value.cframe.Position = Vector3.new(X, Y, Z) 166 | local Encoded = buffer.readu16(RecieveBuffer, BLOCK_START + 16) 167 | local MantissaExponent = Encoded % 0x8000 168 | if MantissaExponent == 0b0_11111_0000000000 then 169 | if Encoded // 0x8000 == 1 then 170 | Value.cframe.RotationY = -math.huge 171 | else 172 | Value.cframe.RotationY = math.huge 173 | end 174 | elseif MantissaExponent == 0b1_11111_0000000000 then 175 | Value.cframe.RotationY = 0 / 0 176 | elseif MantissaExponent == 0b0_00000_0000000000 then 177 | Value.cframe.RotationY = 0 178 | else 179 | local Mantissa = MantissaExponent % 0x400 180 | local Exponent = MantissaExponent // 0x400 181 | local Fraction; 182 | if Exponent == 0 then 183 | Fraction = Mantissa / 0x400 184 | else 185 | Fraction = Mantissa / 0x800 + 0.5 186 | end 187 | local Result = math.ldexp(Fraction, Exponent - 14) 188 | Value.cframe.RotationY = if Encoded // 0x8000 == 1 then -Result else Result 189 | end 190 | return Value 191 | end 192 | 193 | local function WriteEVENT_ServerReplicateCFrame(Value: { timestamps: {[number]: number}, cframes: {[number]: { Position: Vector3, RotationY: number }} }): () 194 | -- Allocate BLOCK: 5 bytes 195 | local BLOCK_START = Allocate(5) 196 | buffer.writeu8(SendBuffer, BLOCK_START + 0, 1) 197 | local Elements_1 = 0 198 | for Key_1, Element_1 in Value.timestamps do 199 | Elements_1 += 1 200 | local OFFSET_0 = Allocate(2) 201 | buffer.writeu16(SendBuffer, OFFSET_0, Key_1) 202 | local OFFSET_1 = Allocate(4) 203 | buffer.writef32(SendBuffer, OFFSET_1, Element_1) 204 | end 205 | buffer.writeu16(SendBuffer, BLOCK_START + 1, Elements_1) 206 | local Elements_1 = 0 207 | for Key_1, Element_1 in Value.cframes do 208 | Elements_1 += 1 209 | local OFFSET_0 = Allocate(2) 210 | buffer.writeu16(SendBuffer, OFFSET_0, Key_1) 211 | local Vector = Element_1.Position 212 | local OFFSET_1 = Allocate(4) 213 | buffer.writef32(SendBuffer, OFFSET_1, Vector.X) 214 | local OFFSET_2 = Allocate(4) 215 | buffer.writef32(SendBuffer, OFFSET_2, Vector.Y) 216 | local OFFSET_3 = Allocate(4) 217 | buffer.writef32(SendBuffer, OFFSET_3, Vector.Z) 218 | local OFFSET_4 = Allocate(2) 219 | if Element_1.RotationY > 65504 then 220 | buffer.writeu16(SendBuffer, OFFSET_4, 0b0_11111_0000000000) 221 | elseif Element_1.RotationY < -65504 then 222 | buffer.writeu16(SendBuffer, OFFSET_4, 0b1_11111_0000000000) 223 | elseif Element_1.RotationY ~= Element_1.RotationY then 224 | buffer.writeu16(SendBuffer, OFFSET_4, 0b1_11111_0000000001) 225 | elseif Element_1.RotationY == 0 then 226 | buffer.writeu16(SendBuffer, OFFSET_4, 0) 227 | else 228 | local float = Element_1.RotationY 229 | local Abosulte = math.abs(float) 230 | local Interval = math.ldexp(1, math.floor(math.log(Abosulte, 2)) - 10) 231 | local RoundedValue = (Abosulte // Interval) * Interval 232 | local Fraction, Exponent = math.frexp(RoundedValue) 233 | Exponent += 14 234 | local Mantissa = math.round(if Exponent <= 0 235 | then Fraction * 0x400 / math.ldexp(1, math.abs(Exponent)) 236 | else Fraction * 0x800) % 0x400 237 | local Result = Mantissa 238 | + math.max(Exponent, 0) * 0x400 239 | + if float < 0 then 0x8000 else 0 240 | buffer.writeu16(SendBuffer, OFFSET_4, Result) 241 | end 242 | end 243 | buffer.writeu16(SendBuffer, BLOCK_START + 3, Elements_1) 244 | end 245 | 246 | local function WriteEVENT_TickRateChanged(Value: { id: number, tickRate: number }): () 247 | -- Allocate BLOCK: 7 bytes 248 | local BLOCK_START = Allocate(7) 249 | buffer.writeu8(SendBuffer, BLOCK_START + 0, 0) 250 | buffer.writeu16(SendBuffer, BLOCK_START + 1, Value.id) 251 | buffer.writef32(SendBuffer, BLOCK_START + 3, Value.tickRate) 252 | end 253 | 254 | local function WriteEVENT_TogglePlayerReplication(Value: { id: number, on: boolean }): () 255 | -- Allocate BLOCK: 4 bytes 256 | local BLOCK_START = Allocate(4) 257 | buffer.writeu8(SendBuffer, BLOCK_START + 0, 1) 258 | buffer.writeu16(SendBuffer, BLOCK_START + 1, Value.id) 259 | buffer.writeu8(SendBuffer, BLOCK_START + 3, Value.on and 1 or 0) 260 | end 261 | 262 | local function WriteEVENT_InitializePlayer(Value: { id: number, player: string }): () 263 | -- Allocate BLOCK: 5 bytes 264 | local BLOCK_START = Allocate(5) 265 | buffer.writeu8(SendBuffer, BLOCK_START + 0, 2) 266 | buffer.writeu16(SendBuffer, BLOCK_START + 1, Value.id) 267 | local Length = #Value.player 268 | buffer.writeu16(SendBuffer, BLOCK_START + 3, Length) 269 | Allocate(Length) 270 | buffer.writestring(SendBuffer, SendOffset, Value.player, Length) 271 | end 272 | 273 | local function WriteEVENT_InitializeExistingPlayers(Value: { { id: number, player: string } }): () 274 | -- Allocate BLOCK: 3 bytes 275 | local BLOCK_START = Allocate(3) 276 | buffer.writeu8(SendBuffer, BLOCK_START + 0, 3) 277 | -- START ARRAY 278 | buffer.writeu16(SendBuffer, BLOCK_START + 1, #Value) 279 | -- Allocate BLOCK: 4 bytes 280 | local ARRAY_START_1 = Allocate(4 * #Value) 281 | for Index = 1, #Value do 282 | local Item_1 = Value[Index] 283 | -- Allocate 2 284 | local OPERATION_OFFSET_0 = ARRAY_START_1 285 | ARRAY_START_1 += 2 286 | buffer.writeu16(SendBuffer, OPERATION_OFFSET_0, Item_1.id) 287 | local Length = #Item_1.player 288 | -- Allocate 2 289 | local OPERATION_OFFSET_1 = ARRAY_START_1 290 | ARRAY_START_1 += 2 291 | buffer.writeu16(SendBuffer, OPERATION_OFFSET_1, Length) 292 | Allocate(Length) 293 | buffer.writestring(SendBuffer, SendOffset, Item_1.player, Length) 294 | end 295 | -- END ARRAY 296 | end 297 | 298 | if not RunService:IsRunning() then 299 | local NOOP = function() end 300 | local Returns = table.freeze({ 301 | ClientReplicateCFrame = { 302 | On = NOOP 303 | }, 304 | ServerReplicateCFrame = { 305 | Fire = NOOP, 306 | FireAll = NOOP, 307 | FireList = NOOP, 308 | FireExcept = NOOP 309 | }, 310 | TickRateChanged = { 311 | Fire = NOOP, 312 | FireAll = NOOP, 313 | FireList = NOOP, 314 | FireExcept = NOOP 315 | }, 316 | TogglePlayerReplication = { 317 | Fire = NOOP, 318 | FireAll = NOOP, 319 | FireList = NOOP, 320 | FireExcept = NOOP 321 | }, 322 | InitializePlayer = { 323 | Fire = NOOP, 324 | FireAll = NOOP, 325 | FireList = NOOP, 326 | FireExcept = NOOP 327 | }, 328 | InitializeExistingPlayers = { 329 | Fire = NOOP, 330 | FireAll = NOOP, 331 | FireList = NOOP, 332 | FireExcept = NOOP 333 | }, 334 | }) 335 | return Returns :: BLINK_EVENTS_SYMBOL 336 | end 337 | 338 | if not RunService:IsServer() then 339 | error("Server network module can only be required from the server.") 340 | end 341 | 342 | local Reliable: RemoteEvent = ReplicatedStorage:FindFirstChild(BASE_EVENT_NAME .. "_RELIABLE_REMOTE") :: RemoteEvent 343 | if not Reliable then 344 | local RemoteEvent = Instance.new("RemoteEvent") 345 | RemoteEvent.Name = BASE_EVENT_NAME .. "_RELIABLE_REMOTE" 346 | RemoteEvent.Parent = ReplicatedStorage 347 | Reliable = RemoteEvent 348 | end 349 | 350 | local Unreliable: UnreliableRemoteEvent = ReplicatedStorage:FindFirstChild(BASE_EVENT_NAME .. "_UNRELIABLE_REMOTE") :: UnreliableRemoteEvent 351 | if not Unreliable then 352 | local UnreliableRemoteEvent = Instance.new("UnreliableRemoteEvent") 353 | UnreliableRemoteEvent.Name = BASE_EVENT_NAME .. "_UNRELIABLE_REMOTE" 354 | UnreliableRemoteEvent.Parent = ReplicatedStorage 355 | Unreliable = UnreliableRemoteEvent 356 | end 357 | 358 | local PlayersMap: {[Player]: BufferSave} = {} 359 | 360 | Players.PlayerRemoving:Connect(function(Player) 361 | PlayersMap[Player] = nil 362 | end) 363 | 364 | local function StepReplication() 365 | for Player, Send in PlayersMap do 366 | if Send.Cursor <= 0 then 367 | continue 368 | end 369 | 370 | local Buffer = buffer.create(Send.Cursor) 371 | buffer.copy(Buffer, 0, Send.Buffer, 0, Send.Cursor) 372 | Reliable:FireClient(Player, Buffer, Send.Instances) 373 | 374 | Send.Size = 64 375 | Send.Cursor = 0 376 | Send.Buffer = buffer.create(64) 377 | table.clear(Send.Instances) 378 | end 379 | end 380 | RunService.Heartbeat:Connect(StepReplication) 381 | Reliable.OnServerEvent:Connect(function(Player: Player, Buffer: buffer, Instances: {Instance}) 382 | RecieveCursor = 0 383 | RecieveBuffer = Buffer 384 | RecieveInstances = Instances 385 | RecieveInstanceCursor = 0 386 | local Size = buffer.len(RecieveBuffer) 387 | while (RecieveCursor < Size) do 388 | -- Read BLOCK: 1 bytes 389 | local BLOCK_START = Read(1) 390 | local Index = buffer.readu8(RecieveBuffer, BLOCK_START + 0) 391 | end 392 | end) 393 | Unreliable.OnServerEvent:Connect(function(Player: Player, Buffer: buffer, Instances: {Instance}) 394 | RecieveCursor = 0 395 | RecieveBuffer = Buffer 396 | RecieveInstances = Instances 397 | RecieveInstanceCursor = 0 398 | local Size = buffer.len(RecieveBuffer) 399 | while (RecieveCursor < Size) do 400 | -- Read BLOCK: 1 bytes 401 | local BLOCK_START = Read(1) 402 | local Index = buffer.readu8(RecieveBuffer, BLOCK_START + 0) 403 | if Index == 0 then 404 | local Value: { timestamp: number, cframe: { Position: Vector3, RotationY: number } } = ReadEVENT_ClientReplicateCFrame() 405 | if Events.Unreliable[0] ~= nil then 406 | Events.Unreliable[0](Player, Value) 407 | end 408 | end 409 | end 410 | end) 411 | 412 | 413 | local Returns = table.freeze({ 414 | StepReplication = StepReplication, 415 | 416 | ClientReplicateCFrame = { 417 | On = function(Listener: (Player: Player, Value: { timestamp: number, cframe: { Position: Vector3, RotationY: number } }) -> ()): () -> () 418 | Events.Unreliable[0] = Listener 419 | return function (): () 420 | if Events.Unreliable[0] == Listener then 421 | Events.Unreliable[0] = nil 422 | end 423 | end 424 | end 425 | }, 426 | ServerReplicateCFrame = { 427 | Fire = function(Player: Player, Value: { timestamps: {[number]: number}, cframes: {[number]: { Position: Vector3, RotationY: number }} }): () 428 | Load() 429 | WriteEVENT_ServerReplicateCFrame(Value) 430 | local Buffer = buffer.create(SendCursor) 431 | buffer.copy(Buffer, 0, SendBuffer, 0, SendCursor) 432 | Unreliable:FireClient(Player, Buffer, SendInstances) 433 | end, 434 | FireAll = function(Value: { timestamps: {[number]: number}, cframes: {[number]: { Position: Vector3, RotationY: number }} }): () 435 | Load() 436 | WriteEVENT_ServerReplicateCFrame(Value) 437 | local Buffer = buffer.create(SendCursor) 438 | buffer.copy(Buffer, 0, SendBuffer, 0, SendCursor) 439 | Unreliable:FireAllClients(Buffer, SendInstances) 440 | end, 441 | FireList = function(List: {Player}, Value: { timestamps: {[number]: number}, cframes: {[number]: { Position: Vector3, RotationY: number }} }): () 442 | Load() 443 | WriteEVENT_ServerReplicateCFrame(Value) 444 | local Buffer = buffer.create(SendCursor) 445 | buffer.copy(Buffer, 0, SendBuffer, 0, SendCursor) 446 | for _, Player in List do 447 | Unreliable:FireClient(Player, Buffer, SendInstances) 448 | end 449 | end, 450 | FireExcept = function(Except: Player, Value: { timestamps: {[number]: number}, cframes: {[number]: { Position: Vector3, RotationY: number }} }): () 451 | Load() 452 | WriteEVENT_ServerReplicateCFrame(Value) 453 | local Buffer = buffer.create(SendCursor) 454 | buffer.copy(Buffer, 0, SendBuffer, 0, SendCursor) 455 | for _, Player in Players:GetPlayers() do 456 | if Player == Except then 457 | continue 458 | end 459 | Unreliable:FireClient(Player, Buffer, SendInstances) 460 | end 461 | end, 462 | }, 463 | TickRateChanged = { 464 | Fire = function(Player: Player, Value: { id: number, tickRate: number }): () 465 | Load(PlayersMap[Player]) 466 | WriteEVENT_TickRateChanged(Value) 467 | PlayersMap[Player] = Save() 468 | end, 469 | FireAll = function(Value: { id: number, tickRate: number }): () 470 | Load() 471 | WriteEVENT_TickRateChanged(Value) 472 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 473 | for _, Player in Players:GetPlayers() do 474 | Load(PlayersMap[Player]) 475 | local Position = Allocate(Size) 476 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 477 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 478 | PlayersMap[Player] = Save() 479 | end 480 | end, 481 | FireList = function(List: {Player}, Value: { id: number, tickRate: number }): () 482 | Load() 483 | WriteEVENT_TickRateChanged(Value) 484 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 485 | for _, Player in List do 486 | Load(PlayersMap[Player]) 487 | local Position = Allocate(Size) 488 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 489 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 490 | PlayersMap[Player] = Save() 491 | end 492 | end, 493 | FireExcept = function(Except: Player, Value: { id: number, tickRate: number }): () 494 | Load() 495 | WriteEVENT_TickRateChanged(Value) 496 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 497 | for _, Player in Players:GetPlayers() do 498 | if Player == Except then 499 | continue 500 | end 501 | Load(PlayersMap[Player]) 502 | local Position = Allocate(Size) 503 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 504 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 505 | PlayersMap[Player] = Save() 506 | end 507 | end, 508 | }, 509 | TogglePlayerReplication = { 510 | Fire = function(Player: Player, Value: { id: number, on: boolean }): () 511 | Load(PlayersMap[Player]) 512 | WriteEVENT_TogglePlayerReplication(Value) 513 | PlayersMap[Player] = Save() 514 | end, 515 | FireAll = function(Value: { id: number, on: boolean }): () 516 | Load() 517 | WriteEVENT_TogglePlayerReplication(Value) 518 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 519 | for _, Player in Players:GetPlayers() do 520 | Load(PlayersMap[Player]) 521 | local Position = Allocate(Size) 522 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 523 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 524 | PlayersMap[Player] = Save() 525 | end 526 | end, 527 | FireList = function(List: {Player}, Value: { id: number, on: boolean }): () 528 | Load() 529 | WriteEVENT_TogglePlayerReplication(Value) 530 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 531 | for _, Player in List do 532 | Load(PlayersMap[Player]) 533 | local Position = Allocate(Size) 534 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 535 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 536 | PlayersMap[Player] = Save() 537 | end 538 | end, 539 | FireExcept = function(Except: Player, Value: { id: number, on: boolean }): () 540 | Load() 541 | WriteEVENT_TogglePlayerReplication(Value) 542 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 543 | for _, Player in Players:GetPlayers() do 544 | if Player == Except then 545 | continue 546 | end 547 | Load(PlayersMap[Player]) 548 | local Position = Allocate(Size) 549 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 550 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 551 | PlayersMap[Player] = Save() 552 | end 553 | end, 554 | }, 555 | InitializePlayer = { 556 | Fire = function(Player: Player, Value: { id: number, player: string }): () 557 | Load(PlayersMap[Player]) 558 | WriteEVENT_InitializePlayer(Value) 559 | PlayersMap[Player] = Save() 560 | end, 561 | FireAll = function(Value: { id: number, player: string }): () 562 | Load() 563 | WriteEVENT_InitializePlayer(Value) 564 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 565 | for _, Player in Players:GetPlayers() do 566 | Load(PlayersMap[Player]) 567 | local Position = Allocate(Size) 568 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 569 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 570 | PlayersMap[Player] = Save() 571 | end 572 | end, 573 | FireList = function(List: {Player}, Value: { id: number, player: string }): () 574 | Load() 575 | WriteEVENT_InitializePlayer(Value) 576 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 577 | for _, Player in List do 578 | Load(PlayersMap[Player]) 579 | local Position = Allocate(Size) 580 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 581 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 582 | PlayersMap[Player] = Save() 583 | end 584 | end, 585 | FireExcept = function(Except: Player, Value: { id: number, player: string }): () 586 | Load() 587 | WriteEVENT_InitializePlayer(Value) 588 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 589 | for _, Player in Players:GetPlayers() do 590 | if Player == Except then 591 | continue 592 | end 593 | Load(PlayersMap[Player]) 594 | local Position = Allocate(Size) 595 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 596 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 597 | PlayersMap[Player] = Save() 598 | end 599 | end, 600 | }, 601 | InitializeExistingPlayers = { 602 | Fire = function(Player: Player, Value: { { id: number, player: string } }): () 603 | Load(PlayersMap[Player]) 604 | WriteEVENT_InitializeExistingPlayers(Value) 605 | PlayersMap[Player] = Save() 606 | end, 607 | FireAll = function(Value: { { id: number, player: string } }): () 608 | Load() 609 | WriteEVENT_InitializeExistingPlayers(Value) 610 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 611 | for _, Player in Players:GetPlayers() do 612 | Load(PlayersMap[Player]) 613 | local Position = Allocate(Size) 614 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 615 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 616 | PlayersMap[Player] = Save() 617 | end 618 | end, 619 | FireList = function(List: {Player}, Value: { { id: number, player: string } }): () 620 | Load() 621 | WriteEVENT_InitializeExistingPlayers(Value) 622 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 623 | for _, Player in List do 624 | Load(PlayersMap[Player]) 625 | local Position = Allocate(Size) 626 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 627 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 628 | PlayersMap[Player] = Save() 629 | end 630 | end, 631 | FireExcept = function(Except: Player, Value: { { id: number, player: string } }): () 632 | Load() 633 | WriteEVENT_InitializeExistingPlayers(Value) 634 | local Buffer, Size, Instances = SendBuffer, SendCursor, SendInstances 635 | for _, Player in Players:GetPlayers() do 636 | if Player == Except then 637 | continue 638 | end 639 | Load(PlayersMap[Player]) 640 | local Position = Allocate(Size) 641 | buffer.copy(SendBuffer, Position, Buffer, 0, Size) 642 | table.move(Instances, 1, #Instances, #SendInstances + 1, SendInstances) 643 | PlayersMap[Player] = Save() 644 | end 645 | end, 646 | }, 647 | }) 648 | type BLINK_EVENTS_SYMBOL = typeof(Returns) 649 | return Returns :: BLINK_EVENTS_SYMBOL -------------------------------------------------------------------------------- /src/Server/Replicate.luau: -------------------------------------------------------------------------------- 1 | local Players = game:GetService("Players") 2 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 3 | local RunService = game:GetService("RunService") 4 | local StarterPlayer = game:GetService("StarterPlayer") 5 | 6 | local Config = require(script.Parent.Parent.Shared.Config) 7 | local Snapshots = require(script.Parent.Parent.Shared.Snapshots) 8 | local Grid = require(script.Parent.Parent.Shared.Grid) 9 | local Events = require(script.Parent.Parent.Events.Server) 10 | local Networkables = require(script.Parent.Parent.Shared.MakeNetworkable) 11 | local Character = require(script.Parent.Parent.Shared.Character) 12 | local InterpolationMath = require(script.Parent.Parent.Shared.InterpolationMath) 13 | 14 | local ClientReplicateCFrame = Instance.new("UnreliableRemoteEvent") 15 | ClientReplicateCFrame.Name = "ClientReplicateCFrame" 16 | ClientReplicateCFrame.Parent = ReplicatedStorage 17 | 18 | local CUSTOM_CHARACTERS = Config.ENABLE_CUSTOM_CHARACTERS 19 | local MAX_UNRELIABLE_BYTES = 900 20 | local SNAPSHOT_SIZE = if Config.SEND_FULL_ROTATION then 22 + 2 else 18 + 2 -- assume worst cast for id (uInt) 21 | local HEADER_SIZE = 2 22 | local MAX_BATCH = (MAX_UNRELIABLE_BYTES - HEADER_SIZE) // SNAPSHOT_SIZE 23 | 24 | local ServerReplicateCFrame = Instance.new("UnreliableRemoteEvent") 25 | ServerReplicateCFrame.Name = "ServerReplicateCFrame" 26 | ServerReplicateCFrame.Parent = ReplicatedStorage 27 | 28 | local DeathEvent = Instance.new("RemoteEvent") 29 | DeathEvent.Name = "DeathEvent" 30 | DeathEvent.Parent = ReplicatedStorage 31 | 32 | local newPlayers = {} 33 | local idStack = {} :: { number } 34 | local playerIdMap = {} :: { [Player]: number } 35 | local clientOwners = {} 36 | 37 | task.defer(function() 38 | local NpcRegistry = (require)(script.Parent.Parent.Shared.NpcRegistry) 39 | clientOwners = NpcRegistry._ClientOwners 40 | end) 41 | 42 | export type ReplicationRule = { 43 | filterType: "include" | "exclude", 44 | filterPlayers: { Player }, 45 | } 46 | 47 | local idMap = {} :: { 48 | [number]: { 49 | player: Player?, 50 | snapshot: Snapshots.Snapshot, 51 | clientLastTick: number?, 52 | 53 | serverOwned: boolean?, 54 | npcType: string?, 55 | model: Model?, 56 | networkOwner: Player?, 57 | 58 | _characterAdded: RBXScriptConnection?, 59 | _characterRemoving: RBXScriptConnection?, 60 | replicationRule: ReplicationRule?, 61 | }, 62 | } 63 | local pausedPlayers = {} :: { [number]: boolean } 64 | 65 | local lastReplicatedTimes = {} :: { [number]: number } 66 | local playerTickRates = {} :: { [number]: number } 67 | local replicators = {} :: { [number]: Model } 68 | 69 | local MAX_ID = 2 ^ 16 - 1 70 | local IncrementalFactoryUID = 0 71 | 72 | local function GetCharacter(player: Player): Model? 73 | if CUSTOM_CHARACTERS then 74 | return Character.GetCharacter(player) 75 | else 76 | return player.Character 77 | end 78 | end 79 | 80 | local Hermite = InterpolationMath.Hermite 81 | local VelocityAt = InterpolationMath.VelocityAt 82 | 83 | local function PackSnapshotData(snapshotBuffer: buffer, offset: number, timestamp: number, cframe: CFrame, id: number) 84 | buffer.writef32(snapshotBuffer, offset + 0, timestamp) 85 | buffer.writef32(snapshotBuffer, offset + 4, cframe.Position.X) 86 | buffer.writef32(snapshotBuffer, offset + 8, cframe.Position.Y) 87 | buffer.writef32(snapshotBuffer, offset + 12, cframe.Position.Z) 88 | 89 | if Config.SEND_FULL_ROTATION then 90 | local networkable = Networkables.MakeNetworkable(cframe) 91 | 92 | local mappedX = math.map(networkable.Rotation.x, -1, 1, 0, 2 ^ 16 - 1) 93 | local mappedY = math.map(networkable.Rotation.y, -1, 1, 0, 2 ^ 16 - 1) 94 | local mappedZ = math.map(networkable.Rotation.z, -1, 1, 0, 2 ^ 16 - 1) 95 | 96 | buffer.writeu16(snapshotBuffer, offset + 16, mappedX) 97 | buffer.writeu16(snapshotBuffer, offset + 18, mappedY) 98 | buffer.writeu16(snapshotBuffer, offset + 20, mappedZ) 99 | buffer.writeu16(snapshotBuffer, offset + 22, id) 100 | else 101 | local networkable = Networkables.MakeYawNetworkable(cframe) 102 | local mappedRotationY = math.map(networkable.RotationY, -math.pi, math.pi, 0, 2 ^ 16 - 1) 103 | buffer.writeu16(snapshotBuffer, offset + 16, mappedRotationY) 104 | buffer.writeu16(snapshotBuffer, offset + 18, id) 105 | end 106 | end 107 | 108 | local function UnpackSnapshotData( 109 | snapshotBuffer: buffer, 110 | offset: number 111 | ): { timestamp: number, cframe: CFrame, id: number } 112 | local value = {} 113 | value.timestamp = buffer.readf32(snapshotBuffer, offset + 0) 114 | 115 | local x = buffer.readf32(snapshotBuffer, offset + 4) 116 | local y = buffer.readf32(snapshotBuffer, offset + 8) 117 | local z = buffer.readf32(snapshotBuffer, offset + 12) 118 | 119 | if Config.SEND_FULL_ROTATION then 120 | local mappedX = buffer.readu16(snapshotBuffer, offset + 16) 121 | local mappedY = buffer.readu16(snapshotBuffer, offset + 18) 122 | local mappedZ = buffer.readu16(snapshotBuffer, offset + 20) 123 | 124 | local rx = math.map(mappedX, 0, 2 ^ 16 - 1, -1, 1) 125 | local ry = math.map(mappedY, 0, 2 ^ 16 - 1, -1, 1) 126 | local rz = math.map(mappedZ, 0, 2 ^ 16 - 1, -1, 1) 127 | 128 | value.cframe = Networkables.DecodeCFrame({ 129 | Position = vector.create(x, y, z) :: any, 130 | Rotation = { x = rx, y = ry, z = rz }, 131 | }) 132 | value.id = buffer.readu16(snapshotBuffer, offset + 22) 133 | else 134 | local rotationY = buffer.readu16(snapshotBuffer, offset + 16) 135 | local remapped = math.map(rotationY, 0, 2 ^ 16 - 1, -math.pi, math.pi) 136 | 137 | value.cframe = Networkables.DecodeYawCFrame({ 138 | Position = vector.create(x, y, z) :: any, 139 | RotationY = remapped, 140 | }) 141 | value.id = buffer.readu16(snapshotBuffer, offset + 18) 142 | end 143 | 144 | return value 145 | end 146 | 147 | local function GetNextID(): number 148 | local reusedID = table.remove(idStack) 149 | if reusedID then 150 | return reusedID 151 | end 152 | 153 | if IncrementalFactoryUID + 1 == MAX_ID then 154 | error("Max ID reached, please investigate.") 155 | end 156 | IncrementalFactoryUID += 1 157 | 158 | return IncrementalFactoryUID 159 | end 160 | 161 | local RandomOffset = Random.new() 162 | local function ReturnID(id: number) 163 | task.delay(RandomOffset:NextNumber(2, 4), table.insert, idStack, id) -- this way we don't immediately reuse ids 164 | end 165 | 166 | local function GetIdFrom(input: Player | Model | number): number? 167 | if typeof(input) == "Instance" then 168 | if input:IsA("Player") then 169 | return playerIdMap[input] 170 | elseif input:IsA("Model") then 171 | return input:GetAttribute("NPC_ID") :: any 172 | end 173 | elseif typeof(input) == "number" then 174 | return input 175 | end 176 | 177 | return nil 178 | end 179 | 180 | local function NewReplicationRule(): ReplicationRule 181 | return { 182 | filterType = "exclude", 183 | filterPlayers = {}, 184 | } 185 | end 186 | 187 | local function GetReplicationRule(input: Player | Model | number): ReplicationRule 188 | local id = GetIdFrom(input) 189 | if not id then 190 | return NewReplicationRule() 191 | end 192 | 193 | local data = idMap[id] 194 | if not data then 195 | return NewReplicationRule() 196 | end 197 | 198 | if not idMap[id].replicationRule then 199 | idMap[id].replicationRule = NewReplicationRule() 200 | end 201 | 202 | return idMap[id].replicationRule :: any 203 | end 204 | 205 | local function SetReplicationRule(input: Player | Model | number, rule: ReplicationRule) 206 | local id = GetIdFrom(input) 207 | if not id then 208 | warn("ID not found for input", input) 209 | return 210 | end 211 | 212 | local data = idMap[id] 213 | if not data then 214 | warn("Data not found for ID", id) 215 | return 216 | end 217 | 218 | data.replicationRule = rule 219 | end 220 | 221 | local function GetNpcConfig(npcType: string?): any 222 | npcType = npcType or "DEFAULT" 223 | return Config.NPC_TYPES[npcType :: any] or Config.NPC_TYPES.DEFAULT 224 | end 225 | 226 | local function OnCharacterAdded(player: Player, character: Model, id: number) 227 | if Config.DISABLE_DEFAULT_REPLICATION then 228 | local humanoid = character:WaitForChild("Humanoid") :: Humanoid 229 | if not humanoid then 230 | return 231 | end 232 | 233 | humanoid.Died:Connect(function() 234 | if not humanoid.BreakJointsOnDeath then 235 | return 236 | end 237 | character:PivotTo(replicators[id]:GetPivot()) 238 | end) 239 | 240 | return 241 | end 242 | Grid.AddEntity(character, "player") 243 | end 244 | 245 | local function OnCharacterRemoving(character: Model) 246 | if Config.DISABLE_DEFAULT_REPLICATION then 247 | return 248 | end 249 | Grid.RemoveEntity(character) 250 | end 251 | 252 | local function InitExistingPlayers(player: Player) 253 | local playerData = {} 254 | 255 | for existingPlayer, _ in playerIdMap do 256 | if existingPlayer == player then 257 | continue 258 | end 259 | 260 | table.insert(playerData, { 261 | id = playerIdMap[existingPlayer], 262 | player = existingPlayer.Name, 263 | }) 264 | end 265 | 266 | if #playerData == 0 then 267 | warn("No existing players found to initialize for player", player) 268 | return 269 | end 270 | 271 | Events.InitializeExistingPlayers.Fire(player, playerData) 272 | end 273 | 274 | Players.PlayerAdded:Connect(function(player: Player) 275 | local id = GetNextID() 276 | playerIdMap[player] = id 277 | 278 | idMap[id] = { 279 | player = player, 280 | snapshot = Snapshots(Hermite), 281 | clientLastTick = nil, 282 | 283 | serverOwned = false, 284 | npcType = nil, 285 | _characterAdded = nil, 286 | _characterRemoving = nil, 287 | } 288 | 289 | lastReplicatedTimes[id] = 0 290 | 291 | if Config.DISABLE_DEFAULT_REPLICATION then 292 | if StarterPlayer:FindFirstChild("Replicator") then 293 | local clone: Model = StarterPlayer.Replicator:Clone() 294 | clone.Name = player.Name 295 | clone.Parent = workspace.CurrentCamera 296 | 297 | replicators[id] = clone 298 | 299 | Grid.AddEntity(clone, "player") 300 | else 301 | warn( 302 | "No Replicator model found in StarterPlayer, you must add one for fully custom replication to work properly" 303 | ) 304 | end 305 | end 306 | 307 | InitExistingPlayers(player) 308 | 309 | Events.InitializePlayer.FireAll({ 310 | id = id, 311 | player = player.Name, 312 | }) 313 | 314 | idMap[id]._characterAdded = player.CharacterAdded:Connect(function(character) 315 | OnCharacterAdded(player, character, id) 316 | end) 317 | idMap[id]._characterRemoving = player.CharacterRemoving:Connect(OnCharacterRemoving) 318 | end) 319 | 320 | Character.CharacterRemoved:Connect(function(player: Player, character: Model) 321 | OnCharacterRemoving(character) 322 | end) 323 | 324 | Character.CharacterAdded:Connect(function(player: Player, character: Model) 325 | if not Config.DISABLE_DEFAULT_REPLICATION then 326 | Grid.AddEntity(character, "player") 327 | end 328 | end) 329 | 330 | Players.PlayerRemoving:Connect(function(player) 331 | local id = playerIdMap[player] 332 | if id then 333 | local _characterAdded = idMap[id]._characterAdded 334 | if _characterAdded then 335 | _characterAdded:Disconnect() 336 | end 337 | 338 | local _characterRemoving = idMap[id]._characterRemoving 339 | if _characterRemoving then 340 | _characterRemoving:Disconnect() 341 | end 342 | 343 | if replicators[id] then 344 | Grid.RemoveEntity(replicators[id]) 345 | pcall(workspace.Destroy, replicators[id]) 346 | replicators[id] = nil 347 | end 348 | 349 | idMap[id] = nil 350 | lastReplicatedTimes[id] = nil 351 | playerIdMap[player] = nil 352 | 353 | ReturnID(id) 354 | end 355 | end) 356 | 357 | ClientReplicateCFrame.OnServerEvent:Connect(function(player: Player, snapshotBuffer: buffer) 358 | local playerId = playerIdMap[player] 359 | SNAPSHOT_SIZE = if Config.SEND_FULL_ROTATION then 24 else 20 360 | 361 | local ownedNpcs = clientOwners[player] or {} 362 | local offset = 0 363 | 364 | for i = 1, buffer.len(snapshotBuffer) // SNAPSHOT_SIZE do 365 | local snapshot = UnpackSnapshotData(snapshotBuffer, offset) 366 | offset += SNAPSHOT_SIZE 367 | local id = snapshot.id - 1 368 | if id == -1 then 369 | id = playerId 370 | end 371 | 372 | local data = idMap[id] 373 | if not data then 374 | continue 375 | end 376 | 377 | if id ~= playerId and not ownedNpcs[id] then 378 | continue 379 | end 380 | data.clientLastTick = snapshot.timestamp 381 | data.snapshot:Push( 382 | snapshot.timestamp, 383 | snapshot.cframe, 384 | VelocityAt(data.snapshot:GetLatest(), snapshot.timestamp, snapshot.cframe) 385 | ) 386 | 387 | lastReplicatedTimes[id] = 0 388 | end 389 | 390 | local character = player.Character 391 | local hrp = character and character.PrimaryPart :: BasePart? 392 | 393 | if Config.DISABLE_DEFAULT_REPLICATION and hrp then 394 | hrp.Anchored = true 395 | end 396 | end) 397 | 398 | DeathEvent.OnServerEvent:Connect(function(player: Player) 399 | local character = player.Character 400 | local humanoid = character and character:FindFirstChildOfClass("Humanoid") :: Humanoid? 401 | 402 | if humanoid and humanoid.BreakJointsOnDeath then 403 | return 404 | end 405 | 406 | if character and character:FindFirstChild("Health") then 407 | local healthScript = character:FindFirstChild("Health") :: Script 408 | healthScript:Destroy() 409 | end 410 | 411 | if humanoid then 412 | humanoid.Health = 0 413 | humanoid.Health = 10 414 | humanoid.Health = 0 415 | end 416 | end) 417 | 418 | local function TogglePlayerReplication(player: Player, on: boolean) 419 | local id = playerIdMap[player] 420 | if not id then 421 | warn("TogglePlayerReplication: Player not found in idMap") 422 | return 423 | end 424 | 425 | if on then 426 | pausedPlayers[id] = nil 427 | Events.TogglePlayerReplication.FireAll({ 428 | id = id, 429 | on = true, 430 | }) 431 | else 432 | pausedPlayers[id] = true 433 | Events.TogglePlayerReplication.FireAll({ 434 | id = id, 435 | on = false, 436 | }) 437 | end 438 | end 439 | 440 | local function UpdateTick(id: number, tickRate: number) 441 | Events.TickRateChanged.FireAll({ 442 | id = id, 443 | tickRate = tickRate, 444 | }) 445 | end 446 | 447 | local function GetTickInterval(character: Model?, id: number): number 448 | local data = idMap[id] 449 | if data and data.serverOwned then 450 | return GetNpcConfig(data.npcType).TICK_RATE 451 | end 452 | 453 | local baseTick = Config.TICK_RATE 454 | if not character then 455 | return baseTick 456 | end 457 | 458 | local model = if Config.DISABLE_DEFAULT_REPLICATION then replicators[id] else character 459 | local nearbyPlayers = Grid.GetNearbyEntities(model, Config.PROXIMITY, { "player" }) 460 | 461 | local multiplier = if Config.DISABLE_DEFAULT_REPLICATION then 4 else 50 462 | local newTickRate = if #nearbyPlayers > 1 then baseTick else baseTick * multiplier 463 | 464 | if newTickRate ~= playerTickRates[id] then 465 | playerTickRates[id] = newTickRate 466 | UpdateTick(id, newTickRate) 467 | end 468 | 469 | return newTickRate 470 | end 471 | 472 | local function Flush(buffers: { buffer }, specificPlayer: Player?) 473 | local count = math.min(#buffers, MAX_BATCH) 474 | if count == 0 then 475 | return false 476 | end 477 | 478 | local snapshotBuffer = buffer.create(count * SNAPSHOT_SIZE) 479 | local offset = 0 480 | 481 | for i = 1, count do 482 | local b: any = table.remove(buffers) 483 | buffer.copy(snapshotBuffer, offset, b, 0, SNAPSHOT_SIZE) 484 | offset += SNAPSHOT_SIZE 485 | end 486 | 487 | if specificPlayer then 488 | ServerReplicateCFrame:FireClient(specificPlayer, snapshotBuffer) 489 | else 490 | ServerReplicateCFrame:FireAllClients(snapshotBuffer) 491 | end 492 | 493 | Flush(buffers, specificPlayer) 494 | 495 | return true 496 | end 497 | 498 | RunService.PostSimulation:Connect(function(deltaTime) 499 | Grid.UpdateGrid() 500 | debug.profilebegin("ReplicateNPCs") 501 | 502 | local players = game:GetService("Players"):GetPlayers() 503 | local playerSpecific = {} 504 | 505 | for _, player in players do 506 | playerSpecific[player] = {} 507 | end 508 | 509 | local allPlayers = {} 510 | local hasNewPlayers = #newPlayers > 0 511 | 512 | for id, data in idMap do 513 | local character = data.player and GetCharacter(data.player) 514 | local isNPC = data.serverOwned == true 515 | 516 | if not isNPC and (not character or not character.PrimaryPart or not data.clientLastTick) then 517 | continue 518 | end 519 | 520 | local tickInterval = GetTickInterval(character or data.model, id) 521 | 522 | local now = os.clock() 523 | local lastReplicated = lastReplicatedTimes[id] 524 | 525 | local inInterval = now - lastReplicated < tickInterval 526 | if inInterval and not hasNewPlayers then 527 | continue 528 | end 529 | 530 | if not inInterval then 531 | lastReplicatedTimes[id] = now 532 | end 533 | 534 | local latestSnapshot = data.snapshot:GetLatest() 535 | local cframe = CFrame.identity 536 | 537 | if latestSnapshot then 538 | cframe = latestSnapshot.value 539 | elseif character and character.PrimaryPart then 540 | cframe = (character :: any).PrimaryPart.CFrame 541 | else 542 | continue 543 | end 544 | 545 | local lastSentCFrame = (data :: any).lastCFrame or CFrame.identity 546 | local changed = vector.magnitude(lastSentCFrame.Position - cframe.Position :: any) >= 0.05 547 | or not lastSentCFrame.Rotation:FuzzyEq(cframe.Rotation :: any, 0.0001); 548 | (data :: any).lastCFrame = cframe 549 | 550 | if CUSTOM_CHARACTERS and character and character.PrimaryPart then 551 | character.PrimaryPart.CFrame = cframe 552 | end 553 | 554 | local t = now 555 | 556 | if data.clientLastTick then 557 | t = data.clientLastTick 558 | elseif not data.clientLastTick and data.networkOwner then 559 | t = 1 560 | end 561 | 562 | if not changed or inInterval then 563 | if hasNewPlayers then 564 | for _, newPlayer: any in newPlayers do 565 | local rule = data.replicationRule 566 | if 567 | not rule 568 | or (rule.filterType == "exclude" and not table.find(rule.filterPlayers, newPlayer)) 569 | or (rule.filterType == "include" and table.find(rule.filterPlayers, newPlayer)) 570 | then 571 | local list = playerSpecific[newPlayer] 572 | if list then 573 | local snapshotBuffer = buffer.create(SNAPSHOT_SIZE) 574 | PackSnapshotData(snapshotBuffer, 0, t, cframe, id) 575 | table.insert(list, snapshotBuffer) 576 | end 577 | end 578 | end 579 | end 580 | continue 581 | end 582 | 583 | local snapshotBuffer = buffer.create(SNAPSHOT_SIZE) 584 | PackSnapshotData(snapshotBuffer, 0, t, cframe, id) 585 | 586 | local replicationRule = data.replicationRule 587 | if not replicationRule or (#replicationRule.filterPlayers == 0 and replicationRule.filterType == "exclude") then 588 | table.insert(allPlayers, snapshotBuffer) 589 | else 590 | if replicationRule.filterType == "include" then 591 | local check = {} 592 | for _, player in replicationRule.filterPlayers do 593 | if not check[player] and playerSpecific[player] then 594 | check[player] = true 595 | table.insert(playerSpecific[player], snapshotBuffer) 596 | end 597 | end 598 | else 599 | local excluded = {} 600 | for _, player in replicationRule.filterPlayers do 601 | excluded[player] = true 602 | end 603 | for player, list in playerSpecific do 604 | if not excluded[player] then 605 | table.insert(list, snapshotBuffer) 606 | end 607 | end 608 | end 609 | end 610 | end 611 | 612 | table.clear(newPlayers) 613 | debug.profileend() 614 | debug.profilebegin("FlushNPCs") 615 | 616 | Flush(allPlayers) 617 | 618 | debug.profileend() 619 | debug.profilebegin("FlushPlayers") 620 | 621 | for player, list in playerSpecific do 622 | Flush(list, player) 623 | end 624 | 625 | debug.profileend() 626 | 627 | debug.profilebegin("Move Client Npcs") 628 | 629 | for player, ids: any in clientOwners do 630 | for id, _ in ids do 631 | local data = idMap[id] 632 | if not data or not data.model or not data.model.PrimaryPart then 633 | continue 634 | end 635 | 636 | local latestSnapshot = data.snapshot:GetLatest() 637 | if latestSnapshot then 638 | data.model.PrimaryPart.CFrame = latestSnapshot.value 639 | end 640 | end 641 | end 642 | 643 | debug.profileend() 644 | 645 | if not Config.DISABLE_DEFAULT_REPLICATION then 646 | return 647 | end 648 | 649 | --Since we disabled roblox replication, we won't be able to easily do collision detections on the server 650 | --This module has each player be represented as a dummy parented to the camera (will not replicate) 651 | -- ~and i simply bulkmoveto them to the latest character CFrame~ BULK move to didn't work with r6 primary part 652 | for id, clone in replicators do 653 | local data = idMap[id] 654 | local primaryPart = clone.PrimaryPart :: BasePart? 655 | if data and data.snapshot and primaryPart then 656 | local latestSnapshot = data.snapshot:GetLatest() 657 | 658 | if latestSnapshot then 659 | primaryPart.CFrame = latestSnapshot.value 660 | end 661 | end 662 | end 663 | end) 664 | 665 | --Exposes a function for other scripts to get the most up to date CFrame of a player or npc 666 | --for my personal usecase, I am using it to bypass the physics buffer for hitbox calculation 667 | 668 | --Since npc visuals is decoupled from the logic, the npc is never moved on the server 669 | --PushNPCTransform doesnt move the rig, it updates the snapshots and fires the clients to interpolate the rig 670 | --if someone wants the rig to actually move, they can just cframe the rig themselves, but that will be double replicated 671 | local function GetLatestCFrame(input: Player | Model | number): CFrame? 672 | local id: number? 673 | 674 | id = GetIdFrom(input) 675 | 676 | if not id then 677 | return nil 678 | end 679 | 680 | local data = idMap[id] 681 | if not data then 682 | return nil 683 | end 684 | 685 | local latestSnapshot = data.snapshot:GetLatest() 686 | if latestSnapshot then 687 | return latestSnapshot.value 688 | end 689 | 690 | return nil 691 | end 692 | 693 | local function GetLatestTime(input: Player | Model | number): number? 694 | local id = GetIdFrom(input) 695 | if not id then 696 | return nil 697 | end 698 | 699 | local data = idMap[id] 700 | if not data then 701 | return nil 702 | end 703 | 704 | local latestSnapshot = data.snapshot:GetLatest() 705 | if latestSnapshot then 706 | return latestSnapshot.t 707 | end 708 | 709 | return nil 710 | end 711 | 712 | local function RegisterNPC(model: Model?, npcType: string?): number 713 | local id = GetNextID() 714 | 715 | local npcConfig = GetNpcConfig(npcType) 716 | 717 | idMap[id] = { 718 | player = nil, 719 | snapshot = Snapshots(Hermite), 720 | clientLastTick = os.clock(), 721 | serverOwned = true, 722 | npcType = npcType or "DEFAULT", 723 | model = model, 724 | } 725 | lastReplicatedTimes[id] = 0 726 | 727 | playerTickRates[id] = npcConfig.TICK_RATE 728 | UpdateTick(id, playerTickRates[id]) 729 | 730 | if model then 731 | model:SetAttribute("NPC_ID", id) 732 | if npcType ~= "DEFAULT" then 733 | model:SetAttribute("NPC_TYPE", npcType) 734 | end 735 | end 736 | 737 | return id 738 | end 739 | 740 | local function UnregisterNPC(id: number): Model? 741 | local data = idMap[id] 742 | if not data then 743 | return 744 | end 745 | 746 | idMap[id] = nil 747 | lastReplicatedTimes[id] = nil 748 | playerTickRates[id] = nil 749 | 750 | ReturnID(id) 751 | 752 | return data.model 753 | end 754 | 755 | local function PushNPCTransform(target: number | Model, cframe: CFrame, t: number?) 756 | local id = GetIdFrom(target) or math.huge 757 | local data = idMap[id] 758 | 759 | if data and data.serverOwned then 760 | local timestamp = t or os.clock() 761 | data.snapshot:Push(timestamp, cframe, VelocityAt(data.snapshot:GetLatest(), timestamp, cframe)) 762 | data.clientLastTick = timestamp 763 | end 764 | end 765 | 766 | return { 767 | _newPlayers = newPlayers, -- This is needed for NpcRegistry 768 | idMap = idMap, 769 | Replicators = replicators, 770 | 771 | GetReplicationRule = GetReplicationRule, 772 | SetReplicationRule = SetReplicationRule, 773 | 774 | GetId = GetIdFrom, 775 | GetLatestCFrame = GetLatestCFrame, 776 | GetLatestTime = GetLatestTime, 777 | 778 | RegisterNPC = RegisterNPC, 779 | UnregisterNPC = UnregisterNPC, 780 | 781 | PushNPCTransform = PushNPCTransform, 782 | 783 | TogglePlayerReplication = TogglePlayerReplication, 784 | 785 | GetAllNetworkIds = function() 786 | local ids = {} 787 | for id, _ in idMap do 788 | table.insert(ids, id) 789 | end 790 | return ids 791 | end, 792 | } 793 | --------------------------------------------------------------------------------