Getting started

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.

How a feature behaves because of the game, AMX Mod X, the protocol or a file format - a timer waits at least 0.1 second, a chat message has one team colour - is noted on that feature's own page, beside what it concerns.

The language

A plugin is TypeScript compiled to WebAssembly and then to machine code. What TypeScript has and a plugin does not:

  • throw takes an Error or a string, which becomes an Error with it as the message: throw 42 does not build. A catch gets an Error - error.message, error.name, instanceof for a class of your own. A crash that is not an error - a stack overflow - ends the call past every catch.
  • In tests an error's stack is its first line: the fake server (amxts test) runs a plugin without the frames a game server keeps. Check error.message; the calls show on a server (errors).
  • No unions of different types. number | string, and cond ? 1 : "a", do not build. Unions of string literals ("CT" | "TERRORIST"), T | null and T | undefined work, and so do true | false | "default" and a function-or-list option such as menu-core's enabled. Use two parameters, two functions, or a common base class.
  • A type alias does not reach itself. type Text = string | ((context: Context) => string) does not build ("Recursive types") when Context has a field of a class with a field of type Text. Write the type out at that field: title: string | ((context: Context) => string).
  • No any and unknown. Every value has a type when the plugin is built.
  • An object literal takes its type from its values, so each value needs one: { owner: null } or { items: [] } alone does not build - declare an interface (const team: Team = { ... }), or write the literal where the type is known.
  • A plain object goes where another is expected when it has the same fields: { y: 2, x: 1 } is a Point of x and y, and a label given goes where label? is optional; one that leaves an optional field out does not - write the literal where the type is known.
  • An object literal's methods have no this of the object: where an interface has methods, a literal of it writes them as arrows or as run() { ... }, and reads the object's fields through a variable.
  • A class goes where an interface of fields is expected when it says implements, and extends no other class: class Spot implements Point is a Point; a class of the same fields without implements is not.
  • JSON.parse is told what it reads: JSON.parse<Settings>(text), or JSON.parse(text) as Settings; alone it does not build. A field the text leaves out that is neither optional nor has a default is a TypeError, where JavaScript leaves it undefined. JSON.stringify takes no replacer function (JSON).
  • Regular expressions have no lookbehind ((?<=...)), named groups, \p{...} or the u and d flags: such a pattern is a SyntaxError. A group that did not match reads as "", not undefined; a function given to replace gets the match and up to four groups. A match that takes more than five million steps throws an Error, so that a pattern that backtracks without end does not hold the server.
  • A date knows a few languages and no Intl: toLocaleString and the rest take a language (en-US, en-GB, de, fr, ru), not options, and toString writes the time zone as its offset, without its name in brackets (time).
  • Syntax that does not build:
    Does not buildWrite instead
    function*, yieldan array, or async functions and await
  • A class field holding an arrow that returns more than a plain value needs its type: onTick = () => { ... } and double = (n: number) => n * 2 build; label = () => this.makeLabel() needs label: () => string = () => this.makeLabel().

Variables and optional values

  • A method that runs early through a base class may read a variable before its declaration as an empty value, where JavaScript throws a ReferenceError.
  • Inside a closure, a boolean left out is false: a flag?: boolean parameter, or a variable that holds an optional boolean field, read in an arrow function made inside the function does not see it was left out - () => flag ?? true is false. Read it outside: const loud = flag ?? true.
  • undefined is known by the type written where it is read: an optional field or parameter, a variable or a function's result of T | undefined, find and map.get - and a variable that holds one of them - print as undefined and count as NaN. An item read by index from an array of T | undefined prints text as null, and a number counted with stays undefined: give it a default with ?? first.
  • x! on a null throws a TypeError right there, where TypeScript's ! does nothing and JavaScript throws only where the value is used.

Async and promises

  • Async generators (async function*) do not build.
  • Promise.race and any need one type: promises of a number and a string, or a number and a sleep, do not build. Await them one by one.
  • Promise.all of different types takes up to 8 promises; promises of one type, or a list, have no such limit (async).
  • A waiting async function keeps up to 4 KB of its own state; one that needs more is stopped, with a message in the console.

Events, forwards and natives

  • A command's handler is written in the call: the object it gets - player and the arguments - is a type the build makes for that call. It goes where the interface of its arguments goes, (args) => kick(args) for kick(args: KickArgs); without one it has no name to give a function written apart: take its parts there, ({ player, text }) => say(player, text). A command's arguments are written in its usage - in place, or after a name made at run time, `${name} [value]`; a usage held in a variable takes none.
  • An event name is written out: server.addEventListener(name, ...) or server.addMessageListener(name, ...) with the name in a variable does not build - the name picks the event's type. So is a "playerChange" listener's field: { field: "spawnProtected" }, not a variable. Without field, the event's value has no type: read the field from event.player.
  • A game listener either always answers or never does. One that should answer only sometimes returns nothing and calls event.preventDefault(), or always answers, repeating the game's rule for the rest (game events).
  • A vector field of a game event is written whole:event.direction = new Vector(x, y, z); event.direction.x = 1 changes a copy (game events).
  • A forward from a Pawn plugin reaches TypeScript only when an include amxts comes with declares it, and a Team or a RoundWinner argument needs the forward declared in an include (forwards).
  • A number in a native's any:... tail is a Float: only where amxts knows the native reads one - engfunc, dllfunc, ExecuteHam, pev and the others natives lists; elsewhere it goes as a whole number. Where such a native also writes text, read that and convert it: nvault_get(vault, key, text) with const text = new Ref(""), then Number(text.value). The tail takes at most twelve arguments.
  • A plugin's own natives are its file's export functions, with the parameter types natives lists; another type stops the build.
  • A native of your own takes up to 64 arguments as Pawn passes them - an array or a string result counts twice, with its size; more stops the build: pass an array instead (natives).
  • A buffer's length is never negative: a native given a buffer and a length below 0 - the -1 that ArrayGetArray, ArrayPushArray and other cellarray natives read as "the whole item" - is not called and answers 0. Pass the buffer's own length.
  • lang.translate takes its arguments as strings: a number goes in as text, `${seconds}` (translations).

Players and entities

  • A value no name stands for - another plugin or a mod wrote it - reads as "unknown", and assigning "unknown" changes nothing. Read the number with the native: get_entvar(player.id, var_rendermode), or, on a server without ReAPI, the engine module's entity_get_int(player.id, EV_INT_rendermode).
  • Plain numbers: autoSwitchWeapon, shotgunReloadStage.
  • No property for: array members (m_rgAmmo, …; the weapons are player.items), var_controller, var_blending. Use get_member and set_member, or fakemeta's get_ent_data and set_ent_data on a server without ReAPI (entities).
  • A vector or a text field read through a native needs its type:get_entvar<Vector>(id, var_origin), get_member<string>(id, m_szAnimExtention). Without it the field reads as a number, and a vector or text field reads as 0, with a line in the console that says what to write.
  • A flag list writes back through push only: pop and splice change the array, not the game. To remove a flag, assign a filtered array (flags).
  • Shared player fields are boolean, number, string, a union of string literals, Player[] or an object of those; never optional, and an object's members are not objects. A name Player already has is refused.

Effects

  • No effect for decals (a bullet hole, a spray - they need a decal's index), for the engine's tracer colours or for a text message (player.showHud is that); send them with message_begin and write_* (effects).

Storage

  • Storage holds text: value.toString() in, parseInt(value) out. A value reads back up to 255 bytes of UTF-8 - about 127 Cyrillic letters (storage).

HTTP

  • A request's body is text: body takes a string - JSON.stringify(value), or an object through useFetch; not a FormData, Blob, ArrayBuffer or URLSearchParams. A form goes as text: body: query.toString() with "Content-Type": "application/x-www-form-urlencoded" (HTTP).
  • Headers are given as an object of names and values, not as a Headers or a list of pairs.
  • new URLSearchParams takes an object, not a query as text: read one with new URL(address).searchParams.
  • No response.body stream: a response is read whole, so a download or an upload has no progress to follow.
  • useFetch's query takes text: { page: `${page}` }.

FTP and SFTP

  • An SSH key is RSA in PEM: ssh-keygen -t rsa -m PEM. Keys in OpenSSH's own format (BEGIN OPENSSH PRIVATE KEY, what ssh-keygen writes by default), ECDSA and Ed25519 keys are not read, and a server whose only host key is Ed25519 cannot be reached. Convert an RSA key with ssh-keygen -p -m PEM -f key (network requests).

Configs

  • The build reads the shape from the source, and refuses, with the place and the fix: an empty list without a type, a defaults variable without a declared type, a list of lists of lists or of objects, a Map of lists or objects, a union that is not of names (number | string), a generic interface or one that extends another, a field named with a string, a type that contains itself.
  • A Map's keys have no . and no [: such a key cannot be read or written.
  • YAML is the part configs use. Not read: anchors and aliases (&, *), tags (!), complex keys (?), directives, several documents, a plain value that goes on for several lines (quote it, or use | or >), tabs in indentation, a key written twice, a key inside [ ]. Each is an error with the file, line and column, and the file reads as empty.
  • Saving writes a YAML list written [a, b] one item per line, and drops a comment after a value on its line and comments inside { } and [ ]. Comments on lines of their own and blank lines are kept.

Shared modules

The details are on shared modules.

  • What cannot cross between plugins does not build: a Map, a Set, a Record with keys of any string, a generic class, a class from the standard library, a type the module does not export, a computed default, a generic or async function, a rest parameter, an exported variable (export let count) - an object of a class of the module's own (export const counter = new Counter()) does cross.
  • An array or object field of a shared object reads as a copy: push on it changes the copy. Use the module's method, or assign the whole field.

Modules and the amxts command

  • A module gives one name: itself as a namespace (imports: [{ from, as }]) or one of its exports (imports: [{ from, name }]) - not a list of its functions or types; menus.Menu is a type of the namespace.
  • A module's meta, requires, defaults and imports are literals: the build reads them without running the module. Besides setup, defineModule has no lifecycle hooks: listen to server events inside setup.
  • Tests run with amxts test, which is bun test; other test runners are not supported.
  • A native the fake server does not answer stops the test with its name; answer it with server.defineNative(name, native) in the test, or in a module's test kit.

The editor

  • The build and amxts typecheck use TypeScript 5, the one the core installs; the editor works with TypeScript 5.9, 6 and 7.
  • A field the editor accepts may be missing in the build: the editor sees the fields every file of the plugins folder adds to Player, the build only those of the files the plugin imports. Add the import of the file that declares it.
  • Live help in menu files is for VS Code only, through the amxts extension (menus).

The server

  • A .ts the server compiles holds the server for the second or two the compile takes: in the middle of a round the game freezes. On a server people play on, deploy .aot files built in a project (installing on a server).