Getting started

Getting started

amxts runs Counter-Strike 1.6 plugins written in TypeScript inside AMX Mod X, next to your Pawn plugins. A plugin is an ordinary .ts file: players are objects, events have types, and the editor knows every field the game has. This page shows a project, a first plugin, how to reload it while you work, and what is different from the TypeScript you already know.

A project

A new project starts with one command:

npm create amxts@latest

It asks for the package manager, the folder, the modules, oxlint and oxfmt, git and the server's folder, writes the project with a first plugin and its test, installs everything and says what to run next. npx amxts init in a folder is the same command; every question is also a flag (the amxts command).

A project is a folder with its plugins and one config file, amxts.config.ts, which lists the modules the project uses and their options:

my-server/
├── package.json
├── amxts.config.ts
├── tsconfig.json       { "extends": "./.amxts/tsconfig.json" }
├── .env                AMXTS_SERVER: the server dev deploys to
├── plugins/
│   └── hello.ts
└── test/
    └── hello.test.ts
// amxts.config.ts
export default 
defineConfig
({
modules
: [
"@amxts/menu-core", "@amxts/config-core", // needed by menu-core ],
menus
: {
file
: "my-server/menu" },
});

defineConfig is global — no import. modules lists the module packages the project uses; each module's options go under its own key (menus is menu-core's), and the editor checks them against the module. pluginsDir ("plugins") and outDir ("dist") say where the plugins are and where the build writes; target ("rehlds") - which server the project is for, whose includes the build reads when there is no server (the server's includes); imports and pawn - what a plugin uses without an import line and which modules are built (auto-imports). See creating a module for the other side.

npx amxts module add menu-core   # installs a module and lists it, with the ones it needs, in amxts.config.ts
npx amxts build                  # the plugins and the modules' plugins into dist/, with plugins.ini
npx amxts build --deploy         # and copies them to the server
npx amxts dev                    # builds, deploys, and again on every save
npx amxts typecheck              # checks the project as the editor does
npx amxts test                   # the project's tests, on a fake server

The project's package.json has them as scripts too: npm run dev, npm run build, npm test. Every command and its options: the amxts command.

The build writes plugins.ini itself: the modules the plugins use first, each after the modules it requires, then the project's plugins. It also writes .amxts/tsconfig.json, which the project's tsconfig.json extends, and .amxts/imports.d.ts: that is how the editor knows the core's API (@amxts/core), the modules by their names, what a plugin uses without an import (auto-imports) and the options in amxts.config.ts (npx amxts prepare writes them alone).

The editor's tooltips - on print, Player, events, fields, the modules' functions - are in English, or in Russian with AMXTS_DOCS_LANG=ru in the project's .env; npx amxts prepare (and every build) switches them. The Russian words are a copy of the API in .amxts/api/ that only the editor reads: the packages in node_modules stay as they were installed, and the plugins are built the same in either language.

Your first plugin

plugin
({
name
: "Hello",
version
: "1.0.0",
author
: "you",
description
: "An example" });
server
.
addCommand
("/hp", ({
player
}) => sayHp(
player
));
server
.
addEventListener
("putInServer", (
event
) => {
print
(0, `${
event
.
player
.
name
} joined`);
}); function sayHp(
player
: Player) {
print
(
player
, `${
player
.
name
}, your HP: ${
player
.
health
}`);
if (
player
.
health
< 50)
player
.
health
= 100;
}

Put the file in the project's plugins/ and run npx amxts build --deploy. A plugin also works without a project: a .ts file in addons/amxts/plugins, with its name in plugins.ini, is compiled by the server, and again on every save, without a map change. What the server needs and where each file goes: installing on a server.

Hot reload

npx amxts dev

dev builds every plugin and deploys it, then watches your plugins folder and the modules the project uses. When you save a file, it rebuilds the plugins that import that file, copies them to the server and reloads the running server over rcon:

✔ my-plugin · 4.1s
✔ 14:03:40 plugins/my-plugin.ts changed: deployed, the server reloaded my-plugin (4.2s)

A change to a shared file rebuilds every plugin that imports it. A compile error shows as file.ts:line:col - message above the line it points at. The server keeps the last good build.

  • AMXTS_SERVER in .env says where the server is, as it does for --deploy. Without it, dev asks for the folder and writes it there.
  • The plugins are compiled for the server's system, Windows or Linux, which the build sees in that folder (hlds.exe or hlds_linux). For a server it cannot see, AMXTS_SERVER_OS=linux in .env or --os linux says it.
  • The reload goes through rcon to 127.0.0.1:27015 (set AMXTS_PORT to use another port). The password is read from rcon_password in the server's cstrike/server.cfg. Without a password the module still reloads a changed .aot by itself, but you do not see its reply.
  • A new plugin is added to the server's plugins.ini and loads with the reload. Without an rcon password it loads with the next reload or map change.
  • If the server is not running, the command only builds and deploys. It never starts or stops hlds.
  • For a server in Docker, npx amxts dev --docker builds into the dist/ the server reads, and the server reloads the plugins itself.

Sections

pagewhat's inside
Pluginplugin, commands, events, timers, chat
Menus: menu-coremenus for a server: from files, with conditions, placeholders and lists - npx amxts module add menu-core
Plugin: menusa quick menu in code: new Menu("!yShop"), addItem({ title, onSelect }), show(player); AMX Mod X's menu natives
Players and entitiesPlayer, Entity: typed properties
Players and entitiesVector, Entity.findAll / create / remove
Players: actionsserver.players, hasModule, team, actions (give, respawn, …)
Flagsflags as arrays of names: player.hideHud = ["money"]
Effectstemporary effects - beams, explosions, sparks: effects.beamCylinder({ ... })
Cvarsserver settings: new Cvar("mp_freezetime"), .number, a "change" event
Game eventsthe game's events (ReAPI hookchains and Ham Sandwich): game.addEventListener("takeDamage", ...)
Forwardsyour own forwards for other plugins: new Forward<number>("name").emit(1), .subscribe(...)
Asyncasync/await, Promise, sleep, cancelling with AbortSignal
Storagestorage that survives map changes, like a Map: new Storage("name").get(key)
HTTPweb requests: useFetch<T>(url) reads JSON into an interface; fetch, URL, as in the browser
Filesfiles like Node: fs.readFileSync, fs.writeFile, fs.readdir
Nativesyour own natives for Pawn plugins: export function
NativesAMXX/ReAPI natives directly, when @amxts/core lacks something
Shared modulesmodules: one instance on the server for every plugin that uses it
Testinga plugin's tests on a fake server, with bun test
CLIthe amxts command: init, dev, build, module add, test, info
Installing on a serveramxts on a game server: the module, addons/amxts, the plugin list
A server in Dockera Linux server in Docker with your project mounted: amxts dev --docker
Limitationswhat amxts cannot do yet, and what to write instead

What's different about the language

A plugin is TypeScript, compiled ahead of time to machine code. Most of it works as you know it; these are the differences you meet first:

  • number is JavaScript's number: 7 / 2 is 3.5, `${hp}` is "100", bitwise operators take it as a 32-bit integer. You never convert numbers for natives: a whole-number field or argument drops the fraction.
  • A union is of string literals or with null: "CT" | "TERRORIST", Player | null, number | undefined. number | string does not build.
  • Closures work as in JavaScript: a listener or a timer uses the variables around it, a for (let ...) loop gives each closure its own i, and this in an arrow is the method's.
  • Options are an interface with optional fields: a field left out is undefined, ?? gives the default, ?. reads through a missing object, and destructuring takes defaults:
    interface GreetOptions {
        
    times
    ?: number;
    loud
    ?: boolean;
    onDone
    ?: (
    player
    : Player) => void;
    } function greet(
    player
    : Player, {
    times
    = 1,
    loud
    = false }: GreetOptions = {}) {
    for (let
    i
    = 0;
    i
    <
    times
    ;
    i
    ++)
    print
    (
    player
    ,
    loud
    ? "HELLO!" : "Hello");
    } function farewell(
    player
    : Player,
    options
    : GreetOptions = {}) {
    if (
    options
    .
    times
    !==
    undefined
    )
    print
    (
    player
    , `${
    options
    .
    times
    } times`);
    options
    .
    onDone
    ?.(
    player
    );
    }
  • A catch gets an Error: throw takes an Error or a string, and catch (error) reads error.message without a cast. An error no try catches ends the current call with its message in the server console, and the plugin runs on.
  • A Record may not have the key asked for: in Record<string, number> a key that was never set reads as undefined, so its values are number | undefined - give a default with ??. A Record whose keys are all written out, Record<"red" | "blue", number>, has them all.

What else the language cannot do in a plugin yet, and what to write instead, is on Limitations.