Writing a place in Luau#
Moud is a game engine that runs inside Minecraft. You write your game in Luau, Moud runs it while Minecraft is running, and you ship the result as a mod. Everything your code creates lives in a tree of instances that the engine draws, collides and replicates for you.
The game you write is called a place. A place is a folder of files: Moud reads the folder when the game starts, runs the entry points it finds inside it, and runs them again every time you save.
Vanilla Minecraft features start switched off. Hunger, fall damage, block breaking, the hotbar and
the sky each have a setting in place.toml, and you turn on only the ones your game needs.
A place is a folder#
A new place has this layout:
place/
place.toml the name, the entry points, and which vanilla features are on
server/main.luau the world, and everyone in it
client/main.luau the camera, the input, and this client's own body
shared/ modules both sides require
scenes/ .scene files laid out in the editor
.moud/types.d.luau generated when the place starts; never edit it
.luaurc the @server, @client and @shared require aliases, written once
.zed/settings.json points Zed's Luau server at the types, kept in sync
.vscode/ the same for VS Code, VSCodium, Cursor and WindsurfTwo files do the work:
server/main.luauruns on the server. It builds the world, decides what happens in it, and is the only side allowed to change what every player sees.client/main.luauruns inside each player's own game, once per player. It sets that player's camera, reads their input and draws their interface.
The rest of the folder supports those two:
place.tomlholds the name of the place, the entry points and a switch for each vanilla Minecraft feature. See Places, modules, scenes and scripts.shared/holds modules that both sides load withrequire, such as a table of weapon stats the server uses to deal damage and the client uses to fill a menu.scenes/holds.scenefiles. A scene is a piece of the world you lay out in the editor instead of writing by hand, and a script loads it when it is needed..moud/types.d.luauis written by the engine every time the place starts. It gives your editor the type of every class and property, which is what makes autocomplete and strict mode work.
There is no registration step and nothing to declare. A file that exists is a file that runs, and adding a script never means editing a list somewhere else.
Editing scripts in your code editor#
Moud's types are read by luau-lsp, the Luau language server. Install it in your editor once and open the place folder, not a folder above it:
- Zed: the Luau extension.
.zed/settings.jsonalready points it at the types. - VS Code, VSCodium, Cursor, Windsurf: the Luau Language Server extension
(
JohnnyMorganz.luau-lsp). The editor offers it when you open the folder, because.vscode/extensions.jsonrecommends it, and.vscode/settings.jsonsets it up. - Neovim: luau-lsp.nvim with
platform = { type = "standard" }andtypes = { definition_files = { ["@moud"] = ".moud/types.d.luau" } }. It reads.luaurcon its own.
Moud writes these files when the place opens. A settings file that starts with
// moud keeps this file in sync belongs to Moud and is rewritten; delete that line and it is yours.
If .vscode/settings.json already exists without it, Moud only adds the settings that are missing
and never changes one you set. A file with comments in it is left alone.
The types also know the open scene. While you edit, every instance you place under the world gets a
type, a second after you stop changing things, so the editor completes game.world. with the names
in your scene and flags a typo:
--!strict
local holds = game.world.Wall1.Holds -- completes Wall1, then Holds
local jug: Part = holds.Jug1 -- a Part, no cast
local wall = game.world:find("Wall2") -- typed as Wall2, with its children
local oops = game.world.Wall4 -- Key 'Wall4' not foundfindknows the name of every child, including names with spaces, soworld:find("Jug 1")is aPart. Any other name still returnsInstance?, for things a script adds while the game runs.- A child whose name is also a property or a method, like a part called
name, is only typed throughfind, becauseinstance.namemeans the property. - Only the scene that is open in the editor is typed. A scene a script loads later is not.
- luau-lsp only reads types when it starts, so Moud starts it for you. The settings point Zed and
VS Code at
.moud/luau-lsp(.moud/luau-lsp.exeon Windows), which runs the real luau-lsp and quietly starts a fresh one whenever the types change, handing it the files you have open. Completions and errors catch up a moment after you rename or add something, without a reload. It uses the luau-lsp your editor downloaded, or the one on yourPATH. - Neovim needs its language server restarted after a change, unless you point luau-lsp.nvim's
server command at
.moud/luau-lsp(or.moud/luau-lsp.exe).
Saving reloads the place#
When you save a file, Moud destroys everything the place created and runs it again from the top. You do not restart Minecraft to test a change, and what you see in the game is always what the code currently says.
Because everything is created again, a variable holding an instance from before the reload points at something that no longer exists. Look instances up at the moment you use them:
-- wrong: this points at an instance the reload destroyed
local statue = game.world:find("statue")
-- right: looked up when it is needed
local function statue()
return game.world:find("statue")
endA reload also clears every variable in your scripts. Values that have to survive one, such as a
score or a round number, go in game.persist, a plain table the engine hands back after each
restart.
game.persist.joins = (game.persist.joins or 0) + 1The server owns the tree, each client mirrors it#
The server holds the real tree of instances. Every player's game holds a copy of it. When a server script creates a part, moves it or changes one of its properties, the engine sends that change to every client and applies it there. Nothing travels back the other way on its own.
That rule decides where each API lives:
server/main.luau |
client/main.luau |
|
|---|---|---|
game.world |
the real tree | a mirror of it |
game.stepped |
yes, once a tick | no |
game.renderStepped |
no | yes, once a frame |
game.players.joined / .leaving |
yes | no |
game.players:me() |
no | yes |
camera, input |
no | yes |
task, game.persist, game.reloaded |
yes | yes |
What a client script adds to the tree stays on that client. It is drawn, other client scripts can find it, and no other player ever sees it. That is what you want for interface, previews and effects only the local player needs.
A client cannot change the world for anybody else, and writing to its own mirror does not ask the server for anything. When a player needs something to happen for everyone, the client sends a message and the server decides what to do with it. See Talking across the boundary.
Everything you create is an instance#
Parts, characters, lights, buttons, sounds and scripts are all instances. You create one with add,
which takes a class name and a table of properties, and returns the new instance:
local floor = game.world:add("Part", {
name = "floor",
size = vec3(40, 1, 40),
cframe = cframe(0, 64, 0),
color = color(0.35, 0.42, 0.38),
anchored = true,
})The properties in that table do the same thing as assigning them afterwards, so the part above is the same part as one you create empty and then fill in. Writing a property later changes the instance immediately, for every player who can see it:
floor.color = color(0.8, 0.2, 0.2)
floor.transparency = 0.5The tree is how you reach an instance again. add parents the new instance to whatever you called it
on, find looks a child up by name, and destroy removes an instance along with everything under
it:
local lamp = floor:add("PointLight", { brightness = 4, range = 12 })
game.world:find("floor") -- the part above
floor:find("PointLight") -- the light inside it
lamp:destroy()Instances also have signals, which are events you connect a function to. The function runs every time the event happens, until you disconnect it or the instance is destroyed:
floor.touched:connect(function(other)
print(other.name .. " stepped on the floor")
end)Every class follows that pattern. Once you know add, find, destroy and connect, a class you
have not used yet is only a list of properties and signals to read.
Every class, property and signal is listed in the Reference, and your editor knows
them too, because the engine writes them into .moud/types.d.luau each time the place starts. A
misspelled property shows up in the editor before you run anything.
A player and their body are two different things#
The player is the person connected to the server. The character is the body they wear in the world. A player can be connected without a body: before they spawn, while they are dead, or the whole time they sit in a menu.
game.players.joined:connect(function(player)
local body = player:spawn(vec3(0, 66, 0))
body.humanoid.walkSpeed = 6
end)A character is an instance like any other, made of parts joined together:
humanoidcarries health and decides how the body moves, through properties such aswalkSpeedandjumpHeightand calls such astakeDamage.animatorplays.animfiles on the body and blends them by priority.backpackholds the tools the player carries, which is what fills their hotbar.
The server spawns bodies and says what happens to them. The client that owns a body is the one that moves it, which is why walking feels immediate, and why the server checks anything that matters. See Characters, Players and bodies and Tools.
Interface, sound and effects are instances too#
There is no separate UI system to learn. A ScreenGui draws over the screen, a BillboardGui floats
over an instance in the world, and a SurfaceGui sticks to one face of a part. All three hold the
same widgets, and the engine reads them every frame, so a property you write shows up on the next
one:
-- client
local hud = game.world:add("ScreenGui", { name = "hud" })
hud:add("TextLabel", {
position = udim2(0, 16, 0, 16),
size = udim2(0, 200, 0, 24),
text = "Score: 0",
textColor = color(1, 1, 1),
backgroundTransparency = 1,
})Sounds, particle emitters, lights and post effects work the same way: you add them to the tree, set
their properties, and destroy them when you are done with them. Even the time of day is an instance:
a Lighting in the world takes the clock, the sky and the weather over. See
Interface, Sound,
Models, lights, post effects and held items and
Lighting, sky, weather and presets.
The two clocks#
The server and the client each have their own loop, and different work belongs in each one:
-- server, once a tick. delta is how many seconds passed
game.stepped:connect(function(delta) end)
-- client, once a frame. delta is how many seconds passed
game.renderStepped:connect(function(delta) end)Game state belongs on stepped, where every player gets the same answer. The camera and anything
that has to look smooth belong on renderStepped, which runs just before the frame is drawn, so the
camera you set there is the camera the world is drawn against. See
Tweens and timing.
Units#
Metres and seconds, everywhere. Angles are radians, and colours run from 0 to 1.
Nothing in a place is stated per tick. walkSpeed = 6 means six metres a second whatever the tick
rate is, and the engine does the dividing.
Press Right Shift in game to open the editor. Its Output panel lists what your scripts printed and every error they raised, and Window → Renderer Tools shows what the renderer and the physics currently think, which is the quickest way to tell whether your place is wrong or the engine is. See Debugging.
