Plugin: commands, events, timers, chat, translations
A plugin is one .ts file in the project's plugins folder. It does its work
at the top level of the file: it names itself, adds commands and listens to
events. What it uses from the core - plugin, server, Player, print -
needs no import line (auto-imports).
plugin({ name: "Welcome", version: "1.0.0", author: "you" });
server.addCommand("/hp", ({ player }) => showHp(player));
server.addEventListener("putInServer", (event) => {
print(event.player, "Welcome to the server!");
});
function showHp(player: Player) {
print(player, `${player.name}, your HP: ${player.health}`);
}
Everything on this page is the core's API, @amxts/core, and is auto-imported.
Auto-imports
A plugin uses the core's API and what the project's modules give without importing it. The build finds the names a file uses and does not declare, and adds an import for each one it knows:
semiclip.rule = (player, target) => player.team == target.team;
server.addCommand("/through", ({ player }) => showThrough(player));
function showThrough(player: Player) {
const names = semiclip.passesThrough(player).map(other => other.name);
print(player, `You walk through: ${names.join(", ")}`);
}
server, Player and print are the core's; semiclip is what
@amxts/resemiclip gives, in a project whose amxts.config.ts lists it. The imports go after the
file's last line, so an error still points at the line you wrote.
- What is auto-imported: everything
@amxts/coreexports for plugins -Player,Entity,server,game,print,lang,effects,Cvar,Forward,Storage, the timers, the events' types (TakeDamageEvent), the names of fields and flags (Team,HideHud) - and each module's name (menus,configs,semiclip). Not the natives and constants (@amxts/core/natives,@amxts/core/constants), not files (@amxts/core/fs), not your own files: a plugin that uses them says so with an import. - A name of your own wins. A variable, a parameter or a function named
serveris yours where it is visible; the auto-import is only for the places that do not see one. - An explicit import works too:
import { Player } from "@amxts/core",import * as menus from "@amxts/menu-core". It is what a plugin writes for a module under another name, and for what is not auto-imported. - What an import names. The core's API by the package's name -
@amxts/core,@amxts/core/natives,@amxts/core/constants,@amxts/core/fs,@amxts/core/os- a module by its package's name, and your own files by~/, which is the plugins folder:import { twice } from "~/lib/math"isplugins/lib/math.ts, from any subfolder.~/names only your files: an import of the core's API by it does not build. - The editor knows the same names:
npx amxts prepare- whichdev,build,typecheckandnpm installrun first - writes.amxts/imports.d.ts, where they are globals, so completion, go to definition and the type check work without an import. It is written again when a command runs after the modules inamxts.config.tschange. - Turning it off:
imports: { autoImport: false }inamxts.config.ts; every plugin then imports what it uses.
// amxts.config.ts
export default defineConfig({
modules: ["@amxts/menu-core"],
imports: { autoImport: false },
});
A module says what it gives in its defineModule - itself as a namespace,
imports: [{ from: "@amxts/menu-core", as: "menus" }], or one of its
exports by its own name, [{ from: "@amxts/resemiclip", name: "semiclip" }]
(creating a module). Two sources of one name stop
the build, which names both.
Only what is used is built
A plugin gets an import only for what it uses, so a module no plugin uses is
compiled into none of them. The build goes further: it builds a module's own
plugin, and lists it in plugins.ini, only when some plugin of the project
uses the module - through an auto-import or an explicit one, itself or through
a file it imports. A module amxts.config.ts lists and no plugin uses is left
out, and the build says so:
i module "resemiclip" is installed but no plugin uses it - left out
A module whose natives Pawn plugins call - menu-core's mc_*, config-core's
cfg_* - is used by plugins the build does not see. pawn keeps it:
// amxts.config.ts
export default defineConfig({
modules: ["@amxts/config-core", "@amxts/menu-core"],
pawn: ["@amxts/menu-core"], // Pawn plugins call mc_*: built even when no plugin uses it
});
What a module requires comes with it. In amxts dev a save that starts using
a module builds the module with the plugin; a module no plugin uses any more
leaves plugins.ini.
Plugin info
plugin({ name: "Welcome", version: "1.0.0", author: "you", description: "Greets players" });
The name, version and author the server lists for the plugin: amxts_plugins
in the server console shows them. description is optional. Call plugin
once, at the top of the file.
Commands since v0.2
interface KickArgs {
target: Player;
reason?: string;
}
server.addCommand<KickArgs>("/kick <target> [reason]", ({ player, target, reason }) => {
print(0, `${player.name} kicks ${target.name}`);
target.kick(reason ?? "Kicked by admin");
}, { access: "kick", description: "Kick a player" });
server.addCommand("/hp", ({ player }) => print(player, `${player.health} HP`));
server.addCommand("myplugin_heal <amount>", ({ player, amount }) => print(player, `heal ${amount}`)); // no slash: a console command
server.addCommand("say rules", ({ player }) => showRules(player)); // a chat line without a slash
The first argument is the command's usage: its name, then its arguments,
<name> one the player must type and [name] one he may leave out. The
handler gets them by those names, with player, the player who typed the
command.
The arguments' types are an interface, named and passed first, as
addCommand<KickArgs> (a type written in place, <{ ... }>, is harder to
read and cannot be used twice). Each field is how its word is read:
| type | the word | e.g. |
|---|---|---|
string | as it is; the last argument takes the rest of the line | reason in /kick bob being rude is "being rude" |
number | a number | /give 5 |
Player | #userid, the whole name or a part of it, any case | /kick bo, /kick #12 |
"on" | "off" | one of those words | /mode on |
[name] in the usage is name?: in the interface, and <name> a field
without ?. A name in the usage and not in the interface - or the other way
round - does not build, and the error says which. Without the interface every
argument is a string: ({ amount }) above is text.
When a word is not what the command takes - a number that is not one, a player nobody's name matches - the player gets the usage in chat, and the handler does not run. A part of a name several players have is answered with their names, so he can type more of it. A word too many is answered the same way, unless the last argument is text, which takes the rest of the line.
- A name with
/is a chat command.say /hpandsay_team /hpboth run it, and the line does not show in chat. - A name without
/is a console command: the player typesmyplugin_heal 50in his console. "say <phrase>"runs when a player writes exactly that phrase in chat:"say rules"runs onrules, not onrules please. It takes no arguments.
Command names are not case-sensitive: /HP runs /hp.
access is the admin right a player needs, as users.ini gives it: "kick",
"ban", "slay", "map", "cvar", "rcon", "levelA" … "levelH" and the
rest - the editor suggests them. For a player without the right the command
does nothing, and his chat line shows as a normal message. description is
what amx_help lists.
A player's own rights are player.access, an array of the same names:
player.access.includes("ban").
A help command
server.commands lists the commands the plugin added - each one's usage,
description and access - which is what a /help prints:
server.addCommand("/help", ({ player }) => {
for (const command of server.commands) {
if (command.access == null || player.access.includes(command.access)) print(player, `${command.usage} - ${command.description}`);
}
});
Server commands
interface ResetArgs {
what?: "scores" | "all";
}
server.addServerCommand<ResetArgs>("myplugin_reset [what]", ({ what }) => {
console.log(`reset: ${what ?? "all"}`);
});
A command of the server console: typed there, sent over rcon, or run by
another plugin with server_cmd. Its usage and arguments are read as a
player's command's are; no player types it, so the handler has no player.
What it prints with console.log goes back to whoever sent the command -
over rcon, into the rcon reply.
Server events
server.addEventListener("putInServer", (event) => {
print(event.player, "Welcome!");
});
server.addEventListener("disconnected", (event) => {
console.log(`${event.player.name} left: ${event.reason}`);
});
server.addEventListener("configsExecuted", loadSettings); // the server has run its configs
server.addEventListener works like addEventListener in the browser: the
first argument is the event's name, the second a function the server calls
every time the event happens. The event's name decides the type of event,
so (event) => ... needs no annotation, and the editor completes the names
and marks a misspelt one. The fields are typed: a player is a Player, a
text a string, a yes/no a boolean (event.dropped).
| Event | When |
|---|---|
"init" | the plugin starts, once all plugins are loaded |
"pluginsLoaded" | every plugin has started: the moment to make a forward other plugins hear |
"configsExecuted" | the map has loaded and the server has run its configs - server.cfg, amxx.cfg, the map's own: the moment to read cvars |
"precache" | the map loads: the moment to precache models and sounds |
"changeLevel" | the map is about to change |
"end" | the plugin stops: the map changes or the server shuts down |
"connect" | a player connects, before he is in the game |
"authorized" | a player's Steam ID is known (event.steamId) |
"putInServer" | a player is in the game |
"disconnected" | a player has left (event.dropped, event.reason) |
"playerChange" | a field plugins added to Player changed (shared player fields) |
"command" | a player sent a console command |
"impulse" | a player sent an impulse: 100 is the flashlight, 201 the spray |
"suicide" | a player typed kill in the console |
"frame" | every server frame |
In "connect", "authorized" and "putInServer" the player has not
spawned yet, so event.player is a Client: his id, name, ip,
steamId, access, team, muted and command, but no health or weapons.
A Player has everything a Client has, so a function that needs only these
takes a Client and works with both.
The editor lists every event; its tooltip names the AMX Mod X forward
under it (client_putinserver). What happens to a player in the game - he
thinks every frame, changes his info, touches something - is a
game event: game.addEventListener("preThink", ...),
"userInfoChange", "touch".
A listener written as a function of its own names the event's type, which is
the forward's name in words: ClientPutinserverEvent,
ClientDisconnectedEvent, ClientImpulseEvent.
server.addEventListener("impulse", onImpulse);
function onImpulse(event: ClientImpulseEvent) {
if (event.impulse == 100) print(event.player, "Flashlight!");
}
server.removeEventListener("impulse", onImpulse) stops it: pass the same
function that was added.
The name is written out as a string: server.addEventListener(name, ...)
with the name in a variable is a build error, because the name picks the
event's type.
A listener is a closure, as in JavaScript: it may use the variables of the function it is written in, and they live as long as it does.
server.addEventListener("putInServer", (event) => {
const player = event.player;
setTimeout(() => print(player, `Glad to see you, ${player.name}!`), 2000);
});
Messages to clients since v0.2
server.addMessageListener("death", (event) => {
if (event.headshot) print(0, `${event.killer?.name} - headshot - ${event.victim?.name}`);
});
Every message the server sends a client - a chat line, a HUD icon, the round
clock, a death notice - is heard through server.addMessageListener, before
it leaves, with its arguments as typed fields. Messages
has the details and every message's name.
Game events
game.addEventListener("newRound", () => console.log("a new round"));
game.addEventListener("takeDamage", (event) => {
if (event.attacker.team == event.player.team) event.preventDefault();
});
What happens in the game - damage, spawns, deaths, round ends, buying - are
events on game, the way the server's are on server. A listener can stop
the game's action or answer in its place. They come from the ReAPI and Ham
Sandwich modules, and most are heard without ReAPI too; see
Game events.
Timers
const handle = setTimeout(() => print(player, "Go!"), 2000); // once, in 2 s
clearTimeout(handle); // stop it before it fires
function countdown(player: Player) {
let left = 3;
const ticking = setInterval(() => {
print(player, `${left}`);
left--;
if (left == 0) clearInterval(ticking);
}, 1000);
}
As in the browser: setTimeout runs a function once after a delay in
milliseconds, setInterval runs it every so many milliseconds, and each
returns a handle that clearTimeout or clearInterval takes. The function
may use and change the variables around it. A function without parameters
goes as itself: setTimeout(startVote, 3000).
Inside an async function, await sleep(1000) waits the same way
(async).
Like every AMX Mod X timer, a timer waits at least 0.1 second and fires on a server frame:
setTimeout(f, 10) runs after about 100 ms.Time and randomness
Date.now(); // milliseconds since 1970
new Date().getHours(); // the hour on the server's clock
new Date().getUTCHours(); // the hour, UTC
const start = performance.now(); // milliseconds, to the microsecond, for measuring
Math.random(); // 0 <= x < 1, a new sequence every start
game.time; // the game's clock: seconds since the map started
Date works as in JavaScript: new Date() is now, the UTC getters
(getUTCHours, ...) and the local ones (getHours, getDate, getMonth,
getFullYear, getTimezoneOffset, ...) - local being the server's time
zone. A date becomes text as JavaScript writes it, in the server's time
zone:
const now = new Date();
now.toString(); // "Tue Sep 29 2026 14:05:09 GMT+0300"
now.toLocaleString(); // "9/29/2026, 2:05:09 PM"
now.toLocaleString("ru-RU"); // "29.09.2026, 14:05:09"
now.toLocaleDateString("de"); // "29.9.2026"
now.toLocaleTimeString("en-GB"); // "14:05:09"
toLocaleString, toLocaleDateString and toLocaleTimeString take the
language: en-US when none is named, en-GB, de, fr, ru and the
languages written as ru (uk, be, kk); another is written as en-US.
game.time is the clock
entity fields that hold a moment are on: grenade.damageTime = game.time + 1.
The attack timers - a weapon's nextPrimaryAttack, a player's nextAttack -
count from now instead: weapon.nextPrimaryAttack = 1 is a second away.
Chat and console
print(player, "Only you see this");
print(0, "Everyone sees this");
print(player, "Health restored!", "center"); // "chat" (the default), "center", "console", "notify"
print({ id: 0, variant: "center" }, "Go!"); // everyone, in the middle of the screen
console.log("to the server console");
print takes a player, a player's id, or 0 for everyone. The third
argument is where the text shows: "chat", "center" (the middle of the
screen), "console" (the player's console) or "notify" (the console too;
the game shows it on screen only with developer 1).
console.log writes a line to the server console, after [amxts];
console.warn and console.error mark theirs as a warning and an error.
Each takes a string, or an Error, which it writes with its stack
(errors).
Colours
A colour in text is a tag: ! and a letter.
print(player, "!g[Shop]!y You bought !rarmor");
| Tag | Colour | Chat | Menu |
|---|---|---|---|
!y | yellow, the usual chat colour | yes | yes |
!r | red | yes | yes |
!d | grey | yes | yes |
!g | green | yes | no |
!b | blue | yes | no |
!t | the reader's team colour | yes | no |
!w | white | no | yes |
!R | aligns the rest of the line right | no | yes |
A letter means the same colour in chat and in menus.
Each place draws the tags it has and drops the others from the text, and nothing warns:
!w and !R are for menus, !g, !b and !t for chat.
"center", "console" and "notify" have no colours: there the text shows
as written, tags included. Tags are case-sensitive: !R is not red.Red, blue, grey and
!t are drawn with a team's colour, and a chat message
has one: the first of them in the line wins, and the others show in it.
"!rRed !bBlue" shows both words red.Errors since v0.2
A call that fails - a throw nobody catches, x! on a null, an index
out of range, a stack overflow - ends that call, not the plugin: the next
event, command or timer runs as usual. The server console names the error
and where it happened.
function price(item: Item | null): number {
return item!.price;
}
function onSelect(): number {
return price(null);
}
A plugin amxts dev compiles keeps a record of its calls: the console names
each call that led to the error, with its file, line and column in your
TypeScript, and shows the line itself under the first call of your own code:
[amxts] myplugin.aot: TypeError: Unexpected 'null' (not assigned or failed cast)
at price (plugins/myplugin/shop.ts:26:9)
26 | return item!.price;
at onSelect (plugins/myplugin/shop.ts:30:15)
at timerFired (node_modules/@amxts/core/as/facade.ts:3772:2)
A plugin amxts build compiles - and one a server compiles from the .ts
files in its plugins folder - keeps no such record, which would slow a
busy plugin down (performance). The
console names the error and, for a throw, an x! or a failed check, the
place of the statement that failed; a stack overflow says its message
alone:
[amxts] myplugin.aot: TypeError: Unexpected 'null' (not assigned or failed cast) (plugins/myplugin/shop.ts:26:9)
An error you catch has the same stack: error.stack is that text, from
where the error was made, and console.error(error) writes it. In a plugin
amxts build compiles, error.stack is the error's first line.
try {
buy(player, item);
} catch (error) {
console.error(error); // "Error: ..." and, in a dev build, the calls under it
}
The line under the first call shows when the server can read your .ts
file, as it can with amxts dev. Calls inside AssemblyScript's own library
- an array's methods,
String- show their name without a place.
Menus since v0.2
Real menus - a shop, an admin menu, a vote - are what the official module menu-core is for: menus in INI, YAML or JSON files the server owner edits without a build, items shown and enabled by conditions,
%placeholders%,
lists of players and of your own rows, menus Pawn plugins add items to, and
the editor checking a menu file as it is typed. Add it with
npx amxts module add menu-core and read Menus.A quick menu in code
Menu is AMX Mod X's own menu as an object: a title, items, and
show(player). It is part of the core and needs no module - for a menu or
two a plugin builds in code.
interface ShopData {
category: string;
}
const shop = new Menu<ShopData>(({ data }) => `!yShop: ${data.category}`);
shop.addItem({
title: "Armor - !y$1000",
enabled: ({ player }) => player.armor < 100,
onSelect: ({ player }) => {
player.armor = 100;
},
});
shop.addItem({
title: ({ player }) => `Heal !d(${player.health} HP)`,
visible: ({ player }) => player.isAlive,
enabled: ({ player }) => player.health < 100,
onSelect: ({ player, menu, data }) => {
player.health = 100;
menu.show(player, data); // stays open
},
});
server.addCommand("/shop", ({ player }) => shop.show(player, { category: "all" }));
- An item is an object: its
title,visible- whether it is shown,enabled- whether it can be chosen, andonSelect, what choosing it does.visibleandenabledaretruewhen left out; a hidden item takes no place, one that cannot be chosen is drawn grey and does nothing. - Each function gets the context:
player, the one the menu is shown to;menu, the menu itself;data, whatshowwas given.Data, the type argument, says what that is - a menu shown without data leaves it out. - A title,
visibleandenabledmay be a function, or a plain value: they are worked out at everyshow, for that player. - After a choice the menu closes.
onSelectthat shows it again keeps it open. - Pages, Back, More and Exit are AMX Mod X's: seven items to a page, the rest on the next ones.
- Colour tags are the ones of chat and menus:
!yyellow,!rred,!dgrey,!wwhite,!Rto the right edge.
Make a menu once, at the top level of the file, and show it as often as needed. The options are the second argument:
const vote = new Menu("!yNext map?", {
perPage: 0, // every item on one page, no Back and More (up to 10)
exit: false, // no Exit item
numberColor: "!y", // the item numbers in yellow, not red
backText: "Back",
nextText: "Next",
exitText: "Close",
});
AMX Mod X's menus
For full control, AMX Mod X's own menu natives are there whole: menu_create,
menu_additem, menu_setprop, menu_display and the rest, with their
constants (MPROP_*, MEXIT_*) - see
calling natives directly.
A handler is given by the name of a public, which publicFor from
@amxts/core/kit makes for a function. Old-style menus - text and the keys
that answer - are showMenu of the same kit.
Translations
Text in the reader's language comes from a dictionary: a file in
data/lang, a section per language and a line per key.
[en]
MYPLUGIN_WELCOME = Welcome, ^4%s^1!
MYPLUGIN_ON = \d[\yOn\d]
[ru]
MYPLUGIN_WELCOME = Добро пожаловать, ^4%s^1!
MYPLUGIN_ON = \d[\yВкл\d]
lang.load("myplugin"); // data/lang/myplugin.txt
server.addEventListener("putInServer", (event) => {
print(event.player, lang.translate(event.player, "MYPLUGIN_WELCOME", [event.player.name]));
});
lang.translate(player, key, args) gives the key's line in the player's
language - his lang setinfo - and null for the player gives the server's
(amx_language). A language the dictionary lacks falls back to the server's.
%s, %d and %f (%.1f, %02d) are filled from args, which are
strings: a number goes in as text, `${seconds}`. A placeholder with no
argument left for it stays as written, and a key no dictionary has comes back
as the key itself.
AMX Mod X reads a dictionary line up to 511 bytes - about 255 Cyrillic letters; the rest of a longer line is cut.
player.language is that language, for what a dictionary does not hold - a
sound, a picture: player.language == "ru". It is his lang setinfo, or the
server's language when he has none or amx_client_languages is 0.
A dictionary writes colours as AMX Mod X does - \y in a menu, ^4 in
chat - and lang.translate gives them back as tags: \y is !y,
\r !r, \d !d, \w !w, \R !R, ^1 !y, ^3 !t, ^4 !g.
So one line works in a menu and in chat; each place drops the tags it cannot
draw (!w in chat). ^n is a line break.
HUD messages
player.showHud("Round 3", { color: [255, 40, 40], y: 0.3, hold: 2 });
server.showHud("For everyone");
Text on the screen outside chat. The options, the large director letters and
HudLine are on Players.
Sounds and precaching
server.precache("myplugin/hit.wav");
server.precache("models/myplugin/box.mdl");
function hit(player: Player) {
player.emitSound("myplugin/hit.wav"); // everyone near hears it
player.emitSound("myplugin/hit.wav", { volume: 0.5, pitch: 120, channel: "voice" });
player.playSound("vox/one.wav"); // he alone, as the radio
}
A model, a sprite or a sound the game uses is precached while the map loads:
server.precache(path) at the top level of the file precaches it then, and
in the "precache" event at once. It also makes players download the file. A
sound is written as the game plays it, under sound/ ("myplugin/hit.wav");
a model or a sprite with its folder ("models/myplugin/box.mdl"). It returns
the file as a Resource, which an effect takes for a sprite or
a model.
entity.emitSound(sample, options) plays a sound from an entity - a player
too - heard by everyone near and fading with distance. Every option has a
default:
| Option | Default | Meaning |
|---|---|---|
channel | "auto" | the entity's channel: "auto", "weapon", "voice", "item", "body", "stream", "static"; a new sound on a channel cuts the one playing there, "auto" never cuts |
volume | 1 | 0 to 1 |
attenuation | 0.8 | how fast it fades with distance: 0 is heard across the map, 2 only close by |
pitch | 100 | in percent: 50 is an octave lower, up to 255 |
player.playSound(sample) plays a sound to one player, heard as it is
wherever he stands, as the radio is.
"precache" event; its top level runs again, and a Resource asked
for there finds the index the map gave the file. A file no plugin precached
while the map loaded waits for the next map.The server
server.map; // "de_dust2"
server.maxPlayers; // 32
server.configsDir; // "addons/amxmodx/configs"
server.dataDir; // "addons/amxmodx/data"
server.command("changelevel de_nuke"); // runs in the server console
server.command runs a line in the server console as if it were typed there.
To run one in a player's console: player.command("say hi").
Limitations
What amxts cannot do yet, each with what to write instead. The build, the editor or the server console says so for almost every item below; this page is where to look up what it means.
Game events
What happens in the game - a player takes damage, spawns, dies, buys a weapon, throws a grenade, a round ends - are events on game. A listener hears the event before the game acts, and can let it go on, change it, stop it, or answer in the game's place: