Skip to content

Client Commands

Some commands have no business on the server. A traffic-debug overlay, a visualiser toggle, a client-only diagnostic. The effect is entirely local to the person running it, and routing it through a remote buys nothing but latency and boilerplate.

:runClient() declares one. It executes on the executor's client, with no server round trip.

luau
export type TrafficArgs = { enabled: boolean }

return function(Command, Types)
	return Command.new("traffic")
		:description("Toggle the traffic debug overlay")
		:arg(Types.Boolean, "enabled", "Show or hide")
		:permission("developer")
		:runClient(function(context, args: TrafficArgs)
			Overlay.setVisible(args.enabled)
			context:reply(if args.enabled then "overlay on" else "overlay off")
		end)
end

A client command module returns a function, not a command

This is the one place the convention differs from a server command, and it is forced: the module is required on both sides. The server registers it and the client executes it, so it cannot hardcode a path to Command that resolves in both places.

Taking Command and Types as parameters lets each side pass its own. A server command file, which only ever runs on the server, still returns the command directly.

Annotate the handler, not the builder

A server command gets its typed argument table from Command.new("kick") :: Operator.Builder<KickArgs>. That cast needs Operator in scope, and a client command module has no Operator to require. That is the whole point of the injected form.

Annotate the handler's args instead, as above. You get the same typed table inside the handler, which is where it matters. context is ClientContext: reply, error, executor, text, path and flags, with no confirm, logAction or jobId.

Point Start at a folder of them, alongside your server commands:

luau
Operator.Start({
	Commands = script.Parent.Commands,
	ClientCommands = script.Parent.ClientCommands,
})

Same one-file-per-command convention. The command namespace is shared, so a client command that collides with a server command is the same loud error as any other collision.

The safety rule

A client command must be safe for its executor to run arbitrarily, as often as they like

A client command's body is delivered to the client, so a player who has it can invoke it directly, bypassing the console entirely. Permission gates delivery, not execution.

If that isn't true of your command, it's a server command. Anything touching replicated state, other players, or anything the server trusts is a server command, full stop.

This is acceptable only because delivery is permission-gated and the effects are local to that one player. An unauthorised player never receives the command or its module, and has no way to tell it exists.

A client command cannot target another player

Not discouraged, impossible. There is no mechanism, because reaching another client requires the server. context exposes no way to do it, and a client-reported audit record carries no targets field at all.

If a command needs a target, it's a server command.

The client context

context on a client command exposes what is meaningful locally, and omits what isn't. Members are absent rather than present-and-nil, so a typo fails loudly.

MemberClientServer
context.executorthe local player, never nilPlayer?, nil for a server-run command
context.text
context.path
context.flags
context:reply(msg)
context:error(msg)
context.jobIdabsent
context:confirm(prompt)absent
context:logAction(…)absent

jobId is absent because it is server-generated identity; read game.JobId yourself if you want it for a bug report.

logAction is absent because it writes the moderation log. A client self-reporting a moderation action is exactly the kind of unverifiable entry the audit log is designed to keep out, and a client command cannot affect another player anyway.

Why there is no confirm, and why :destructive() is rejected

:destructive() on a client command is an error at registration, naming the command.

On a server command :destructive() is enforced: the server asks, and fails closed if the answer never comes. On a client command the prompt would be one the executor can simply skip: same spelling, weaker guarantee, and no way to tell them apart at the call site. A developer reading :destructive() would reasonably assume the stronger meaning.

If you genuinely want a confirmation prompt in a client command, write one. Losing the convenience is a fair price for the API never lying about what it enforces.

Dispatch

  • Client commands never send a dispatch. The client sees the client-run flag in the manifest and executes locally.
  • Parsing, binding and argument transforms all run locally, exactly as they already do for autocomplete. No round trip.
  • :permission() and guards gate the manifest, server-side. Once delivered, execution is local and unchecked, which is the consequence the safety rule exists for.
  • A throwing client command reports into console history and is isolated; it cannot break the console, the bundle, or later dispatches.
  • A client command whose module failed to load reports cleanly in console rather than failing the bundle boot, and does not silently fall through to the server.

Client.UI = false still delivers client commands. They are a dispatch feature, not a UI feature.

Audit: client-reported records

A client command that never reaches the server would be invisible to the audit log. So the client sends a record notification, for auditing only. It never gates permission, never blocks execution, and if it is rate-limited or lost the command still runs.

These records are marked source = "client" and shown as client-reported in the log panel. They are not server-verified.

FieldWho sets itTrust
id, timestamp, jobIdserververified
executorId, executorNameserver, from the remote invocationverified
path, text, status, ok, message, argsclientclient-reported
targetsforced emptya client command cannot target anyone

The server refuses a report unless the path resolves to a registered client command that this player is actually permitted to run, with a known status and every length and count capped.

This does not make the record verified, it bounds the lie

A modified client can misreport within those limits. More importantly, omission cannot be prevented at all: a modified client can simply not report, and nothing on the server will notice.

Absence of a client-reported record proves nothing. A moderator reading the log needs to know that, which is why source is a field on the record rather than a footnote.

Released under the MIT Licence.