Last Update: 2023-07-06
Networking and Multiplayer
This tutorial walks through creating mods that handle networking and multiplayer functionality in Don't Starve Together. We'll build a mod that synchronizes custom data between clients and the server.
Project Overview
We'll create a mod that adds a "Player Notes" system with:
- Ability to write notes that are visible to all players
- Real-time synchronization of note data between clients
- Proper handling of player join/leave events
- Network-efficient updates using RPC calls
Step 1: Set Up the Mod Structure
Create these folders and files:
playernotes_mod/
├── modinfo.lua
├── modmain.lua
├── scripts/
│ ├── components/
│ │ └── playernotes.lua
│ ├── screens/
│ │ └── playernotescreen.lua
│ └── widgets/
│ └── playernotesui.lua
└── images/
└── notes_assets.tex
└── notes_assets.xml
Step 2: Create the modinfo.lua File
name = "Player Notes"
description = "Add shareable notes between players"
author = "Your Name"
version = "1.0.0"
-- Compatible with Don't Starve Together
dst_compatible = true
-- Not compatible with Don't Starve
dont_starve_compatible = false
reign_of_giants_compatible = false
-- This mod is required on all clients
all_clients_require_mod = true
-- This mod is not a client-only mod
client_only_mod = false
-- Icon displayed in the server list
icon_atlas = "modicon.xml"
icon = "modicon.tex"
-- Tags that describe your mod
server_filter_tags = {
"utility",
"multiplayer"
}
-- Configuration options
configuration_options = {
{
name = "max_note_length",
label = "Max Note Length",
options = {
{description = "Short (100 chars)", data = 100},
{description = "Medium (250 chars)", data = 250},
{description = "Long (500 chars)", data = 500}
},
default = 250
},
{
name = "note_history",
label = "Note History",
options = {
{description = "Keep 5 notes", data = 5},
{description = "Keep 10 notes", data = 10},
{description = "Keep 20 notes", data = 20}
},
default = 10
}
}
Step 3: Create the Player Notes Component
Create scripts/components/playernotes.lua
:
local PlayerNotes = Class(function(self, inst)
self.inst = inst
self.notes = {}
self.max_notes = GetModConfigData("note_history") or 10
self.max_note_length = GetModConfigData("max_note_length") or 250
-- Initialize network variables
self.net_dirty = false
if TheWorld.ismastersim then
-- Server-side initialization
self.inst:DoTaskInTime(0, function() self:SyncNotes() end)
-- Listen for player join events to sync notes
self.inst:ListenForEvent("ms_playerleft", function(world, player)
-- When a player leaves, update the player list
self:SyncNotes()
end, TheWorld)
self.inst:ListenForEvent("ms_playerjoined", function(world, player)
-- When a player joins, send them the notes
self:SyncNotes()
end, TheWorld)
end
end)
-- Add a new note (server-side)
function PlayerNotes:AddNote(author, content)
if not TheWorld.ismastersim then
-- Client should send RPC instead
return
end
-- Sanitize and trim content
content = content:sub(1, self.max_note_length)
content = content:gsub("<[^>]+>", "") -- Remove HTML-like tags
-- Create the note
local note = {
author = author,
content = content,
timestamp = os.time()
}
-- Add to notes list
table.insert(self.notes, note)
-- Trim list if it exceeds max_notes
while #self.notes > self.max_notes do
table.remove(self.notes, 1)
end
-- Mark as dirty to sync
self.net_dirty = true
-- Sync to all clients
self:SyncNotes()
return true
end
-- Sync notes to clients
function PlayerNotes:SyncNotes()
if not TheWorld.ismastersim then
-- Only the server should sync
return
end
-- Reset dirty flag
self.net_dirty = false
-- Prepare data for network
local data = {}
for i, note in ipairs(self.notes) do
table.insert(data, {
author = note.author,
content = note.content,
timestamp = note.timestamp
})
end
-- Serialize data
local serialized = json.encode(data)
-- Send to all clients
for i, player in ipairs(AllPlayers) do
if player.userid then
SendModRPCToClient(GetClientModRPC("PlayerNotes", "SyncNotes"), player.userid, serialized)
end
end
end
-- Receive notes from server (client-side)
function PlayerNotes:ReceiveNotes(serialized)
if TheWorld.ismastersim then
-- Server shouldn't receive notes
return
end
-- Deserialize data
local success, data = pcall(function() return json.decode(serialized) end)
if success and data then
self.notes = data
-- Notify UI to update
self.inst:PushEvent("noteschanged", { notes = self.notes })
end
end
-- Get all notes
function PlayerNotes:GetNotes()
return self.notes
end
-- Clear all notes (server-side)
function PlayerNotes:ClearNotes()
if not TheWorld.ismastersim then
-- Client should send RPC instead
return
end
self.notes = {}
self.net_dirty = true
self:SyncNotes()
return true
end
return PlayerNotes
Step 4: Create the Notes UI Widget
Create scripts/widgets/playernotesui.lua
:
local Widget = require "widgets/widget"
local Image = require "widgets/image"
local Text = require "widgets/text"
local TextEdit = require "widgets/textedit"
local ScrollableList = require "widgets/scrollablelist"
local Button = require "widgets/button"
-- Define our custom widget
local PlayerNotesUI = Class(Widget, function(self, owner)
-- Call the parent constructor
Widget._ctor(self, "PlayerNotesUI")
-- Store reference to the player
self.owner = owner
-- Initialize widget
self:SetupUI()
-- Listen for note changes
self.owner:ListenForEvent("noteschanged", function(inst, data)
self:RefreshNotes(data.notes)
end)
end)
-- Set up the UI elements
function PlayerNotesUI:SetupUI()
-- Create the background panel
self.bg = self:AddChild(Image("images/notes_assets.xml", "notes_bg.tex"))
self.bg:SetSize(500, 400)
-- Add title
self.title = self:AddChild(Text(TITLEFONT, 30))
self.title:SetPosition(0, 170, 0)
self.title:SetString("Player Notes")
self.title:SetColour(1, 1, 1, 1)
-- Add close button
self.close_btn = self:AddChild(ImageButton("images/notes_assets.xml", "close_button.tex"))
self.close_btn:SetPosition(230, 170, 0)
self.close_btn:SetOnClick(function() self:Hide() end)
-- Create notes list container
self.notes_root = self:AddChild(Widget("notes_root"))
self.notes_root:SetPosition(0, 30, 0)
-- Create text entry for new notes
self.note_entry = self:AddChild(TextEdit(CHATFONT, 20, ""))
self.note_entry:SetPosition(0, -140, 0)
self.note_entry:SetForceEdit(true)
self.note_entry:SetCharacterLimit(GetModConfigData("max_note_length") or 250)
self.note_entry:SetWidth(400)
self.note_entry:SetHeight(60)
-- Add submit button
self.submit_btn = self:AddChild(ImageButton("images/notes_assets.xml", "submit_button.tex"))
self.submit_btn:SetPosition(200, -140, 0)
self.submit_btn:SetOnClick(function() self:SubmitNote() end)
-- Add clear button
self.clear_btn = self:AddChild(ImageButton("images/notes_assets.xml", "clear_button.tex"))
self.clear_btn:SetPosition(-200, -140, 0)
self.clear_btn:SetOnClick(function() self:ClearNotes() end)
-- Hide by default
self:Hide()
end
-- Submit a new note
function PlayerNotesUI:SubmitNote()
local content = self.note_entry:GetString()
if content and content:len() > 0 then
-- Send RPC to server
SendModRPCToServer(GetModRPC("PlayerNotes", "AddNote"), content)
-- Clear the input
self.note_entry:SetString("")
end
end
-- Clear all notes
function PlayerNotesUI:ClearNotes()
-- Send RPC to server
SendModRPCToServer(GetModRPC("PlayerNotes", "ClearNotes"))
end
-- Refresh the notes display
function PlayerNotesUI:RefreshNotes(notes)
-- Clear existing notes
self.notes_root:KillAllChildren()
-- No notes to display
if not notes or #notes == 0 then
local empty_text = self.notes_root:AddChild(Text(CHATFONT, 20))
empty_text:SetString("No notes yet. Be the first to add one!")
empty_text:SetPosition(0, 0, 0)
return
end
-- Create scrollable list
local list_items = {}
-- Create items for each note
for i, note in ipairs(notes) do
local item = Widget("note_item_" .. i)
-- Format timestamp
local timestamp = note.timestamp and os.date("%Y-%m-%d %H:%M", note.timestamp) or "Unknown time"
-- Add author and timestamp
local header = item:AddChild(Text(CHATFONT, 16))
header:SetString(note.author .. " - " .. timestamp)
header:SetPosition(0, 15, 0)
header:SetColour(0.7, 0.7, 1, 1)
-- Add content
local content = item:AddChild(Text(CHATFONT, 18))
content:SetString(note.content)
content:SetPosition(0, -10, 0)
content:SetMultilineTruncatedString(note.content, 3, 400)
-- Set item size
item.height = 60
table.insert(list_items, item)
end
-- Create scrollable list
self.notes_list = self.notes_root:AddChild(ScrollableList(list_items, 450, 250))
self.notes_list:SetPosition(0, 0, 0)
end
-- Toggle visibility
function PlayerNotesUI:ToggleVisibility()
if self:IsVisible() then
self:Hide()
else
self:Show()
-- Request latest notes when showing
if TheWorld.ismastersim then
-- Server already has the data
self:RefreshNotes(TheWorld.components.playernotes:GetNotes())
else
-- Client needs to request from server
SendModRPCToServer(GetModRPC("PlayerNotes", "RequestNotes"))
end
end
end
return PlayerNotesUI
Step 5: Create the Note Screen
Create scripts/screens/playernotescreen.lua
:
local Screen = require "widgets/screen"
local PlayerNotesUI = require "widgets/playernotesui"
local PlayerNoteScreen = Class(Screen, function(self, owner)
Screen._ctor(self, "PlayerNoteScreen")
self.owner = owner
-- Create the main UI widget
self.notes_ui = self:AddChild(PlayerNotesUI(owner))
self.notes_ui:SetPosition(0, 0, 0)
-- Set up input handlers
self:SetupInputHandlers()
end)
-- Set up input handlers
function PlayerNoteScreen:SetupInputHandlers()
-- Close on ESC key
self.cancel_handler = TheInput:AddKeyDownHandler(KEY_ESCAPE, function()
self:Close()
end)
end
-- Close the screen
function PlayerNoteScreen:Close()
TheFrontEnd:PopScreen(self)
end
-- Clean up when the screen is removed
function PlayerNoteScreen:OnDestroy()
if self.cancel_handler then
self.cancel_handler:Remove()
self.cancel_handler = nil
end
Screen.OnDestroy(self)
end
return PlayerNoteScreen
Step 6: Create the modmain.lua File
-- Import globals into the environment
GLOBAL.setmetatable(env, {__index = function(t, k) return GLOBAL.rawget(GLOBAL, k) end})
-- Add asset files
Assets = {
-- UI assets
Asset("IMAGE", "images/notes_assets.tex"),
Asset("ATLAS", "images/notes_assets.xml"),
}
-- Require our custom modules
local PlayerNotes = require "components/playernotes"
local PlayerNoteScreen = require "screens/playernotescreen"
-- Add custom strings
STRINGS.NAMES.PLAYERNOTES = "Player Notes"
STRINGS.UI.PLAYERNOTES = {
TITLE = "Player Notes",
SUBMIT = "Submit",
CLEAR = "Clear All",
EMPTY = "No notes yet. Be the first to add one!",
PLACEHOLDER = "Type your note here...",
}
-- Define RPC handlers
AddModRPCHandler("PlayerNotes", "AddNote", function(player, content)
if TheWorld.components.playernotes then
local author = player.name or "Unknown Player"
TheWorld.components.playernotes:AddNote(author, content)
end
end)
AddModRPCHandler("PlayerNotes", "ClearNotes", function(player)
if TheWorld.components.playernotes then
TheWorld.components.playernotes:ClearNotes()
end
end)
AddModRPCHandler("PlayerNotes", "RequestNotes", function(player)
if TheWorld.components.playernotes then
TheWorld.components.playernotes:SyncNotes()
end
end)
-- Define client RPC handlers
AddClientModRPCHandler("PlayerNotes", "SyncNotes", function(serialized)
if ThePlayer and ThePlayer.components.playernotes then
ThePlayer.components.playernotes:ReceiveNotes(serialized)
end
end)
-- Add the component to the world (server-side)
AddPrefabPostInit("world", function(inst)
if TheWorld.ismastersim then
inst:AddComponent("playernotes")
end
end)
-- Add the component to the player (client-side)
AddPlayerPostInit(function(inst)
if not TheWorld.ismastersim or inst == ThePlayer then
inst:AddComponent("playernotes")
end
-- Add key handler to open notes screen (client-side only)
if inst == ThePlayer then
TheInput:AddKeyDownHandler(KEY_N, function()
if not TheFrontEnd:GetActiveScreen() or TheFrontEnd:GetActiveScreen().name == "HUD" then
TheFrontEnd:PushScreen(PlayerNoteScreen(inst))
end
end)
end
end)
-- Add a button to the pause menu
AddClassPostConstruct("screens/pausescreen", function(self)
if self.menu and self.menu.items then
self.menu:AddItem("Player Notes", function()
self:Close()
TheFrontEnd:PushScreen(PlayerNoteScreen(ThePlayer))
end, "Open shared player notes")
end
end)
-- Print instructions when the game starts
AddSimPostInit(function()
print("Player Notes mod loaded! Press 'N' to open the notes screen.")
end)
Step 7: Create the UI Assets
For a complete mod, you'll need to create these asset files:
- UI Textures:
images/notes_assets.tex
andnotes_assets.xml
- Contains textures for the panel background, buttons, and icons
- Should include "notes_bg.tex", "close_button.tex", "submit_button.tex", and "clear_button.tex"
Step 8: Testing Your Networking Mod
- Launch Don't Starve Together
- Enable your mod in the Mods menu
- Start a new server and invite a friend to join
- Test the networking features by:
- Opening the notes screen with the 'N' key
- Adding notes and verifying they appear on both clients
- Having your friend add notes and checking that they sync to your client
- Disconnecting and reconnecting to verify notes persist
Understanding Networking in Don't Starve Together
DST uses a client-server architecture for networking:
Server and Client Roles
-
Server: Authoritative source of truth for game state
- Runs on the host's machine or a dedicated server
- Processes all game logic and physics
- Sends state updates to clients
-
Client: Displays the game state and sends player inputs
- Receives updates from the server
- Sends player actions to the server
- Can run prediction for smoother gameplay
Network Components
-
NetVars: Synchronized variables that automatically replicate
- Used for simple data that changes frequently
- Examples: health, position, animation state
-
RPCs (Remote Procedure Calls): Function calls across the network
- Used for events and complex data
- Can be client-to-server or server-to-client
-
Events: Local notifications that can trigger network updates
- Used to respond to state changes
- Can be used with RPCs to sync changes
The Master Sim
Don't Starve Together uses a concept called "master simulation" (mastersim):
TheWorld.ismastersim
indicates if the current instance is the authoritative server- Only the server should make authoritative changes to game state
- Clients should send requests to the server via RPCs
- The server then updates the state and broadcasts changes
Key Networking Concepts
1. Server Authority
Always remember that the server is the authority. Important rules:
-- Check if we're on the server before making changes
if TheWorld.ismastersim then
-- Make authoritative changes
entity.components.health:SetVal(100)
else
-- Send request to server
SendModRPCToServer(GetModRPC("MyMod", "SetHealth"), entity, 100)
end
2. Data Synchronization
There are multiple ways to sync data:
- NetVars: For simple, frequently changing data
-- In component initialization
self.damage = net_float(inst.GUID, "weapon.damage", "damagechanged")
-- When setting the value (server-side)
self.damage:set(new_damage)
-- When getting the value (either side)
local current_damage = self.damage:value()
-- Listening for changes (client-side)
inst:ListenForEvent("damagechanged", OnDamageChanged)
- RPCs: For complex data or infrequent updates
-- Define the RPC handler (in modmain)
AddModRPCHandler("MyMod", "SyncData", function(player, entity_id, data_json)
local entity = Ents[entity_id]
if entity and entity.components.mycomponent then
entity.components.mycomponent:ReceiveData(data_json)
end
end)
-- Send data from server to clients
for i, player in ipairs(AllPlayers) do
SendModRPCToClient(GetClientModRPC("MyMod", "SyncData"), player.userid, entity.GUID, json.encode(data))
end
3. Network Efficiency
Be mindful of network bandwidth:
- Throttle Updates: Don't send updates every frame
-- Only sync if enough time has passed
if self.last_sync_time + SYNC_INTERVAL < GetTime() then
self:SyncToClients()
self.last_sync_time = GetTime()
end
- Delta Compression: Only send changes, not the entire state
-- Track what has changed
self.dirty_fields = {}
-- When a field changes
function MyComponent:SetField(name, value)
if self.fields[name] ~= value then
self.fields[name] = value
self.dirty_fields[name] = true
end
end
-- When syncing
function MyComponent:SyncToClients()
if next(self.dirty_fields) then
-- Only send dirty fields
SendModRPCToClient(GetClientModRPC("MyMod", "SyncFields"), player.userid, json.encode(self.dirty_fields))
self.dirty_fields = {}
end
end
4. Player Join/Leave Handling
Handle players joining and leaving properly:
-- Listen for player events
inst:ListenForEvent("ms_playerjoined", function(world, player)
-- Send current state to the new player
self:SyncToPlayer(player)
end, TheWorld)
inst:ListenForEvent("ms_playerleft", function(world, player)
-- Clean up any player-specific data
self.player_data[player.userid] = nil
-- Notify remaining players
self:BroadcastPlayerLeft(player)
end, TheWorld)
Customization Options
Here are some ways to enhance your networking mod:
Add Player-Specific Data
-- In the component, store player-specific data
function PlayerNotes:InitializePlayer(player)
if not self.player_data then
self.player_data = {}
end
self.player_data[player.userid] = {
last_read_note = 0,
favorite_notes = {}
}
end
-- Add functions to work with player data
function PlayerNotes:MarkNoteAsFavorite(player, note_id, is_favorite)
if not self.player_data[player.userid] then
self:InitializePlayer(player)
end
if is_favorite then
table.insert(self.player_data[player.userid].favorite_notes, note_id)
else
for i, id in ipairs(self.player_data[player.userid].favorite_notes) do
if id == note_id then
table.remove(self.player_data[player.userid].favorite_notes, i)
break
end
end
end
-- Sync this player's favorites
self:SyncPlayerData(player)
end
Add Real-Time Collaboration
-- Add a "currently typing" indicator
function PlayerNotesUI:StartTyping()
-- Send RPC to server
SendModRPCToServer(GetModRPC("PlayerNotes", "PlayerTyping"), true)
end
function PlayerNotesUI:StopTyping()
-- Send RPC to server
SendModRPCToServer(GetModRPC("PlayerNotes", "PlayerTyping"), false)
end
-- In the component, track who's typing
function PlayerNotes:SetPlayerTyping(player, is_typing)
if not self.typing_players then
self.typing_players = {}
end
if is_typing then
self.typing_players[player.userid] = player.name
else
self.typing_players[player.userid] = nil
end
-- Broadcast typing status to all players
self:BroadcastTypingStatus()
end
-- Add a visual indicator in the UI
function PlayerNotesUI:UpdateTypingIndicator(typing_players)
if not self.typing_indicator then
self.typing_indicator = self:AddChild(Text(CHATFONT, 16))
self.typing_indicator:SetPosition(0, -170, 0)
self.typing_indicator:SetColour(0.7, 0.7, 0.7, 1)
end
local names = {}
for userid, name in pairs(typing_players) do
table.insert(names, name)
end
if #names > 0 then
self.typing_indicator:SetString(table.concat(names, ", ") .. " typing...")
self.typing_indicator:Show()
else
self.typing_indicator:Hide()
end
end
Add Data Persistence
-- In the component, add save/load functions
function PlayerNotes:OnSave()
return {
notes = self.notes
}
end
function PlayerNotes:OnLoad(data)
if data and data.notes then
self.notes = data.notes
self.net_dirty = true
end
end
-- Add to world entity
AddPrefabPostInit("world", function(inst)
if TheWorld.ismastersim then
inst:AddComponent("playernotes")
-- Add save/load hooks
local old_OnSave = inst.OnSave
inst.OnSave = function(inst, data)
if old_OnSave then
old_OnSave(inst, data)
end
data.playernotes = inst.components.playernotes:OnSave()
end
local old_OnLoad = inst.OnLoad
inst.OnLoad = function(inst, data)
if old_OnLoad then
old_OnLoad(inst, data)
end
if data and data.playernotes and inst.components.playernotes then
inst.components.playernotes:OnLoad(data.playernotes)
end
end
end
end)
Common Issues and Solutions
Problem: Data not syncing between clients
Solution: Make sure you're checking TheWorld.ismastersim correctly and using RPCs properly
Problem: Network spam causing lag
Solution: Throttle updates and only send what has changed
Problem: Data lost when server restarts
Solution: Implement proper save/load functions for persistence
Problem: Late-joining players don't get data
Solution: Handle the ms_playerjoined event to sync data to new players
Problem: Race conditions in network code
Solution: Use a task delay to ensure components are fully initialized:
-- Delay initialization to ensure all components are ready
inst:DoTaskInTime(0.1, function()
if TheWorld.components.playernotes then
TheWorld.components.playernotes:SyncToClients()
end
end)
Next Steps
Now that you've created a networked mod, you can:
- Add More Features: Create additional synchronized systems
- Improve Efficiency: Optimize your network code for better performance
- Add Persistence: Save and load custom data between game sessions
- Create Multiplayer Tools: Build collaborative tools for players
For more advanced networking, check out the Network System documentation to learn about the full capabilities of the networking system.