Moud

What protects players and servers#

A game on Moud runs code on other people's computers. Players run your client scripts the moment they join, your server runs whatever a player's client sends it, and in a Collaborate session several people write into the same folder. This page is about the checks Moud makes on the way, so you know what the engine already refuses for you and what is still yours to check in your own scripts.

Most of it never shows. An honest game passes every check without noticing, and the limits sit far above what a real game does.

What a script can reach#

A Luau script reaches the engine through its globals and nothing else: the instance tree, signals, task, the libraries in the reference, and the place's own files through require and files.read. It has no os.execute, no io, no debug library and no way to load bytecode. Objects from the engine reach a script as handles it can index, never as Java objects it could reflect on.

The one way out of the sandbox is Java: the java and mixin globals, the mixins/ folder and the languages that are not sandboxed, Java place scripts and Revo (see Other languages). Those only exist for code the machine's owner trusts.

Where code comes from#

Every script host knows where its place came from:

The place was It counts as
opened from the project hub or the editor local
given at launch with -Pplace, or the place folder next to the game local
the game inside an exported game's jar local
a game joined from Moud's catalog, a friend or a link downloaded
a place the server moved you to inside a downloaded game downloaded
a Collaborate working copy, which the host writes downloaded

Packages#

A package is Luau source pinned by a SHA-256 hash in place.toml. It runs in the same sandbox as the place's own scripts, with nothing extra. Installing runs no code and nothing updates on its own. A package that calls http or http.websocket has to declare it: the editor shows it before you install, and the engine checks it while the package runs. A package whose files no longer match their pin does not load.

Java access#

A place asks for Java in place.toml:

toml
[trust]
java = true

What it gets depends on where it runs:

  • A project you opened yourself gets java, mixin, its mixins/ files and the unsandboxed languages, on the client and on the server the editor runs for it.
  • A downloaded game never gets them, whatever its place.toml says. Its scripts see java and mixin as nil, its mixins/ files are not run, and a place written in Java or Revo does not start. The log says the game asked and was refused.
  • A dedicated server also needs whoever runs it to allow it, because a hosted server often runs a place someone else wrote. Start it with -Dmoud.trust.java=true. Without it the server logs that place.toml asked and runs the place without Java.
  • Editor plugins never get them, in any project. A plugin from the catalog runs in every project you open, so it can't borrow the trust of one.

The setting is read when the place opens. Changing it later, by hand or by a Collaborate upload, takes effect the next time you open the project. Without it a local place has no Java either, and a script that uses it fails with java is nil, it only exists in a trusted local place.

Turning java on runs the place's scripts with everything your account can do. Only turn it on for code you wrote or read. Opening a project someone sent you with java = true already in it is the same as running a program they sent you.

Bytecode is never run#

Luau does not check the bytecode it loads, so a crafted chunk could corrupt the game's memory. Moud compiles every script itself, from source, each time it loads one, and publishing ships client scripts as source (see Publishing a game). A file that starts with --!moud-bytecode, the marker older versions of Moud wrote, is refused with an error that names it:

res://client/main.luau is precompiled bytecode from an older Moud. Bytecode cannot be checked, so it is not run. The game has to be published again

A version published before scripts shipped as source keeps its compiled scripts, and they stop with that error until the game is published again. Nothing in the place has to change; publishing it once more is enough.

Limits#

Every VM has two limits, on the client and on the server, for place scripts, parallel actors, editor plugins, the loading screen and generation scripts alike:

Limit Default Changed with
time a script runs before it waits 10 seconds -Dmoud.script.seconds=20
memory one VM holds 512 MB -Dmoud.script.megabytes=1024

The limits belong to the machine, not to the game, so a downloaded game cannot raise them. Each parallel actor and each generation thread is its own VM with its own memory limit.

Time. The clock starts when the engine hands a script control, through a resume, a signal or a require, and stops when the script waits or returns. A script that runs past the limit stops at the line it reached:

server/main.luau:2: this script ran for more than 10 seconds without waiting and was stopped, give long loops a task.wait() (-Dmoud.script.seconds raises the limit)

Only that thread stops. Every other thread, connection and script keeps running, and the error shows in the Output panel like any other. A pcall around the loop does not save it: once the time is up, the thread stops again at its next call or loop until it waits or returns.

Memory. An allocation that would take the VM past its limit is refused, and the script that asked for it stops with this script went over the 512 MB of memory a vm may hold and was stopped. The collector frees what it held, and the rest of the game goes on.

A game with real work to do in one go, such as building a large map at start, spreads it over frames with task.wait() rather than raising the limit.

What reviewers read is what players run#

A published version waits for a moderator before anyone can play it. The moderator reads your scripts on the review page, which shows the files from the version's client bundle. Players never download that bundle: they fetch the version's files one by one, each by its hash.

So the catalog ties the two together when you publish. Every file players would get has to be in the client bundle with exactly the same bytes, or the publish is refused with unreviewed_content and the names of the files that did not match. Pictures, sounds and videos are the only files allowed to stay out of the bundle, since they never run as code. A bundle that lists the same file twice is refused too, because the review page could show one copy while players got the other.

The review page also lists what reviewers should look at closely:

  • Scripts reach for Java or the mixin hooks. A script that names the java or mixin globals, or reaches the globals by name with getfenv or _G[...], is pointed out with the file and the name it uses.
  • Contains compiled scripts nobody can read. A script has to be source. A file that starts with the bytecode marker, or holds bytes no text file does, is pointed out wherever it is, in the players' files or the server's.

Neither stops a publish on its own, a reviewer decides, but a version that hides what it does is not going to be accepted.

Remotes only reach what the player was sent#

Every instance has an id, and ids count up as instances are made. A player's client names an instance by its id when it fires a remote or passes an instance along, so a modified client could try ids it was never sent, such as a remote you keep in ServerStorage for your own server scripts.

The server answers only for instances that were replicated to that player. A remote they were not sent does not exist as far as their messages go, and an instance argument they were not sent arrives as nil.

luau
-- server/main.luau
local storage = game.world:add("ServerStorage")
local restart = storage:add("RemoteFunction", { name = "restartRound" })
restart.onServerInvoke = function(player)
    startRound()
    return true
end

local inspect = game.world:add("Remote", { name = "inspect" })
inspect.onServer:connect(function(body, part)
    if part == nil then return end
    print(body.name, "looked at", part.name)
end)

restartRound waits in server storage, so no client has been sent it. A client that invokes its id anyway gets "that remote function no longer exists", the same answer as for a remote that was destroyed.

inspect is in the world, so players can fire it, but the part they name has to be one they were sent. With streaming on, that means it is near them; a part across the map, or one inside server storage, comes through as nil. That is why the handler checks for nil before using it. Any value a client sends is still the client's word, so check it the way you would check a number or a string.

Chat#

Chat has a flood check: a player can send a few messages at once and then about one and a half a second. The check happens as a message comes in, before it is queued, so a client that sends thousands at once costs the server nothing and hears "You're sending messages too quickly" once.

What a server can make a player keep#

A server pushes changed .anim and .bbmodel files to its players while a place is edited live. Each player keeps at most 1024 of those copies and 128 MB in all, and drops what comes past that. The server stops taking new ones from editors at the same limit, since it keeps them to send to everyone who joins later.

Joining from outside the game#

A moud:// link opened from a browser or another program, a play click on the website and a party leader taking the party into a game all start a game without the player pressing anything in Moud. Each of them asks first, with the game's name and who invited the player, and nothing happens until they press Join.

A join made on the website while Moud is closed waits on the player's account for three minutes and is used once. The moud:// handler is only registered for launchers that can start an instance from a command line, and it only takes an id from the link: the address of the server always comes from the catalog.

The catalog also checks the place name every fleet reports for its servers. Players' clients use it as a folder name, so it is letters, digits, _ and - only, and a server that reports anything else is not listed.

Collaborate#

A Collaborate session hands real power to the people in it. An editor can change every script, server scripts included, and the host's computer runs them. Add people as Can edit only when you would let them run code on your machine; everyone else is View only.

Collaborators are accounts, not names#

The list in .moud/team.json keeps each collaborator's Minecraft account id next to their name:

json
{
  "collaborators": [
    { "id": "069a79f4-44e9-4726-a5be-fca90e38aaf5", "name": "Alex", "role": "edit" },
    { "id": "61699b2e-d327-4a01-9f1e-0ea8c3f06bc6", "name": "Sam", "role": "view" }
  ]
}

A player is let in by the account they joined with, so someone who picks Alex's name gets nothing. When you add a name in Permissions, the host looks up the account the way the server whitelist does. A file from before ids were kept is moved over the first time the session opens; a name with no account behind it stays in the file but lets nobody in. A role other than edit reads as view only, so a typo never gives edit rights.

What travels#

Only the kinds of file a place is made of are shared, both ways:

Scripts .luau, .lua
Data .scene, .json, .toml, .txt, .md, .csv, .anim, .bbmodel, .pose
Pictures, sound and video .png, .jpg, .webp, .gif, .bmp, .hdr, .exr, .tga, .ogg, .wav, .mp3, .flac, .opus, .mp4, .webm, .mkv, .mov, .m4v
Models and fonts .glb, .gltf, .bin, .obj, .ttf, .otf

Hidden files and folders such as .moud, .git and editor settings stay on each computer, and so do build and node_modules. Java, jars, native libraries and shell scripts never travel, and a compiled script is refused. A place holds at most 10,000 shared files and 1 GB; a save past that is refused and the editor keeps your copy in drafts.

Your working copy#

A collaborator's working copy only takes the host's files from that list. When you join and your copy has files the host does not, they are moved to .moud/drafts instead of being deleted, and the Output panel says how many. When the host removes a file you have edited since it arrived, your version goes to drafts before the file goes.

Viewers and busy editors#

A view only member can not hold parts, so they can not lock anyone out of editing. Presence, the selection and camera every editor sends, is limited to ten updates a second, and a joining collaborator can ask for the file list and the files a few times, not endlessly.

The block tools have limits of their own: one change touches at most 4,000,000 blocks, a stack makes at most 64 copies, and an extrude goes at most 64 layers deep.