First Project

This walkthrough builds one working feature end to end: a coin counter that gives every player 100 coins five seconds after they join, replicates the total to their UI in real time and lets the client fetch it on demand. It touches a Service, a Controller, a Signal, a Property and a Client method, the four pieces you'll reuse in almost everything you build with OwlKnit.

Drop these two files into the structure from the previous page: CoinService.lua under Services/, CoinController.lua under Controllers/.

1. The Service

ServerScriptService/Services/CoinService.lua
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Owl = require(ReplicatedStorage.OwlKnit.Owl)
 
local CoinService = Owl.CreateService({
    Name = "CoinService",
    Client = {
        CoinsChanged = Owl.CreateSignal(),
        TotalCoins = Owl.CreateProperty(0),
    },
})
 
CoinService._data = {}
 
function CoinService:OwlInit()
    print("[CoinService] Init.")
end
 
function CoinService:OwlStart()
    print("[CoinService] Started.")
end
 
function CoinService:OwlOnPlayerAdded(plr: Player)
    self._data[plr.UserId] = 0
    self.Client.TotalCoins:SetFor(plr, 0)
 
    -- > // Give 100 coins after 5 seconds
    task.delay(5, function()
        if game.Players:GetPlayerByUserId(plr.UserId) then
            self:AddCoins(plr, 100)
        end
    end)
end
 
function CoinService:OwlOnPlayerRemoving(plr: Player)
    self._data[plr.UserId] = nil
end
 
function CoinService:AddCoins(plr: Player, amount: number)
    local current = self._data[plr.UserId]
    if current == nil then return end
 
    local new = current + amount
    self._data[plr.UserId] = new
 
    self.Client.TotalCoins:SetFor(plr, new)
    self.Client.CoinsChanged:Fire(plr, amount, new)
end
 
function CoinService.Client:GetMyCoins(plr: Player): number
    local token = Owl.GetPlrToken(plr)
    if not token then return 0 end
    return CoinService._data[plr.UserId] or 0
end
 
return CoinService

A few things worth pointing out before moving to the client:

  • OwlOnPlayerAdded is one of Owl's automatic hooks, no Players.PlayerAdded:Connect(...) boilerplate, Owl calls it for you and it also fires for players already in the game when the Service starts.
  • TotalCoins is a Property: its value is replicated automatically, so the client never has to poll for it.
  • CoinsChanged is a Signal: fired once per coin gain, for one-off reactions like a floating +100 animation, that's the distinction between the two.
  • CoinService.Client:GetMyCoins is a Client method, a typed RemoteFunction the Controller can call and await like a normal async function. Notice the Owl.GetPlrToken(plr) check: it confirms plr is the same player who actually invoked the remote, guarding against a spoofed argument.

2. The Controller

StarterPlayerScripts/Controllers/CoinController.lua
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Owl = require(ReplicatedStorage.OwlKnit.Owl)
local CoinController = Owl.CreateController({ Name = "CoinController" })
 
function CoinController:OwlInit()
    self.CoinService = Owl.GetService("CoinService")
end
 
function CoinController:OwlStart()
    -- > // Observe the Property (UI always up to date)
    self.CoinService.TotalCoins:Observe(function(total)
        print("Total:", total)
    end)
 
    -- > // Listen to the Signal (gain animation)
    self.CoinService.CoinsChanged:Connect(function(added, total)
        print(string.format("+%d coins! (Total: %d)", added, total))
    end)
 
    -- > // Fetch the current coin count on startup
    self.CoinService:GetMyCoins()
        :andThen(function(coins)
            print("Coins at login:", coins)
        end)
        :catch(warn)
end
 
return CoinController

Observe fires immediately with the current value and again on every future change exactly what you want for driving a UI label. Connect only fires on new events going forward, which is why it's the Signal used for the transient "coins just changed" animation rather than the persistent total.

Notice the Controller never touches self.CoinService._data it can't, that table only exists on the server. Everything the Controller sees came through Client.CoinsChanged, Client.TotalCoins or Client:GetMyCoins(), which is the whole point of the Services & Controllers boundary.

3. Run it

If Init.server.lua and Init.client.lua are already pointed at the Services/Controllers folders (see Installation), there's nothing else to wire up press Play. You should see, in order: [CoinService] Init. and [CoinService] Started. in the server output, Total: 0 and Coins at login: 0 on the client and five seconds later, +100 coins! (Total: 100) followed by the Property's Observe callback firing again with the new total.

Where to go next