Moud

Checking player input#

Anything a client sends can be anything. A remote's accepts already refuses the wrong kinds (see What a message may carry), but it cannot tell a speed of 5 from a speed of 5000, or a name of 12 letters from one of 12000. @moud/validate checks the rest on the server: ranges, lengths, choices and the shape of tables.

A shop is the usual case. The client says what it wants to buy, and the server decides everything else: whether the item exists, what it costs and whether the player can pay.

luau
-- server
local v = require("@moud/validate")

local SHOP = {
    sword = { price = 50, item = "minecraft:iron_sword" },
    bow = { price = 80, item = "minecraft:bow" },
}
local names = {}
for name in SHOP do
    table.insert(names, name)
end

local buy = game.world:add("Remote", { name = "buy", accepts = "string" })

game.players.joined:connect(function(player)
    player.leaderstats:add("NumberValue", { name = "Coins", value = 100 })
end)

buy.onServerPlayer:connect(v.guard({ v.oneOf(names) }, function(player, name)
    local coins = player.leaderstats:find("Coins") :: NumberValue
    local entry = SHOP[name]
    if player.character == nil or coins.value < entry.price then
        return
    end
    coins.value -= entry.price
    player.character.backpack:add("Tool", { name = name, item = entry.item })
end))
  • The client only ever sends a name. The price comes from SHOP on the server, so a changed client cannot buy a sword for nothing.
  • v.oneOf(names) drops any name that is not in the shop before the handler runs, so SHOP[name] is never nil.

v.guard(checks, handler) hands back a function for onServer or onServerPlayer. It checks the arguments after the sender against checks, in order, and calls handler only when all of them pass. A call that fails is dropped and warned about, once per sender, so a client spamming bad calls does not flood the output.

Checks#

Each check is a function that takes a value and hands back true, or false and the reason.

Check Passes
v.any() anything but nil
v.boolean() true or false
v.number({ min, max, integer, allowInfinite }) a number in range. NaN never passes, and infinity only with allowInfinite
v.integer({ min, max }) a whole number in range
v.string({ min, max, pattern, allowEmpty }) text, its length counted in characters. Empty text only with allowEmpty or min = 0. Invalid UTF-8 never passes
v.oneOf({ ... }) one of the values listed
v.literal(value) exactly that value
v.vector3({ maxMagnitude }) a finite Vector3, no longer than maxMagnitude
v.instance(className?) an instance that still exists, of that class when one is given
v.typeOf(name) a value whose typeof is name, like "CFrame"
v.optional(check) nil, or what check passes
v.union(a, b, ...) what any of the checks passes
v.list(check, { min, max }) a list without holes, every item passing check
v.map(keyCheck, valueCheck, { max }) a table whose keys and values pass
v.shape({ field = check }, { extra }) a table with these fields, and no others unless extra is true

Checking a table#

A guard drops a bad call quietly. When you want to know why, or answer the player, run the checks yourself. A loadout a menu sends as one table is checked with v.shape:

luau
-- server
local v = require("@moud/validate")

local loadouts = store("loadouts")
local saveLoadout = game.world:add("Remote", { name = "saveLoadout", accepts = "table" })

local LOADOUT = v.shape({
    primary = v.oneOf({ "rifle", "shotgun", "smg" }),
    perks = v.list(v.string({ max = 20 }), { max = 3 }),
})

saveLoadout.onServerPlayer:connect(function(player, choice)
    local ok, why = v.check(LOADOUT, choice)
    if not ok then
        warn(player.name, "sent a bad loadout:", why)
        return
    end
    loadouts:set(player.id, choice)
end)
  • v.check(check, value) runs one check and hands back true, or false and the reason.
  • v.args(...) makes one function that checks a call's arguments in order, the same way guard does. More arguments than checks fails too.

The reason names what was wrong and where: perks: item 3: expected at most 20 characters, got 31. Keep it for your logs. A player who sent it on purpose does not need to be told.