Exploring the roots and future of Internet Relay Chat

mIRC Scripting Language: A Deep Dive

My first mIRC script was a quit message randomizer. It picked a random line from a text file and used it as my quit message. I was 15 and thought this was the height of programming. Twenty-five years later, I've written scripts that managed botnets of Eggdrops, scripts that interfaced with SQL databases through DLL calls, scripts that implemented entire web servers using mIRC's socket API. The mIRC scripting language is strange, inconsistent, poorly documented, and more capable than anyone who hasn't used it would guess. This article is the reference I wish I'd had in 1998.

The Language Nobody Designed

mIRC's scripting language was not designed in any formal sense. It grew organically across dozens of versions, with Khaled Mardam-Bey adding features as users requested them. The result is a language with no specification, no grammar definition, no formal type system, and no coherent design philosophy. It has variables, but they're all strings. It has flow control, but the syntax differs between aliases and remote events. It has functions (called "identifiers"), but they use $ prefix notation and some are properties of objects that don't quite exist as objects.

The three main script components:

  • Aliases — Stored in the Aliases editor. Define custom commands and identifiers.
  • Remotes — Stored in the Remote editor. Event handlers triggered by IRC events.
  • Popups — Right-click menu entries. Not covered here — they're just aliases with a menu UI.

Script files use .mrc extension by convention. They can be loaded via /load -rs script.mrc (remote) or /load -a script.mrc (aliases). Multiple script files can be loaded simultaneously and they share global scope — which is both useful and dangerous.

; Basic syntax rules
; - Comments start with semicolon
; - Commands start with / in the input line, no / in scripts
; - Variables: %var (local or global)
; - Local var: var %x = value (exists only in current alias/event)
; - Global var: set %x value (persists across sessions, stored in vars.ini)
; - Identifiers: $identifier or $identifier(parameters)
; - String concatenation: $+ (joins without space)
; - Line continuation: | (pipe) separates multiple commands on one line
;
; Example:
; alias greet {
;   var %name = $1
;   msg $active Hello %name $+ , welcome!
; }

Aliases: Custom Commands

Aliases are the simplest script construct. An alias defines either a command (executed with /aliasname) or an identifier (called with $aliasname). If the alias returns a value, it's an identifier. If it performs actions, it's a command. The same alias can be both.

; Command alias — join multiple channels at once
alias joinall {
  join #channel1,#channel2,#channel3
}

; Identifier alias — returns a formatted timestamp
alias ts {
  return $asctime(yyyy-MM-dd HH:nn:ss)
}
; Usage: echo -a Current time: $ts
; Output: Current time: 2023-11-12 14:23:05

; Alias with parameters — $1 is first param, $2 second, $1- is all params
alias say {
  if (!$1) {
    echo -a Usage: /say  
    return
  }
  msg $1 $2-
}
; Usage: /say #mychannel Hello everyone

; Multi-purpose alias: command and identifier
alias randcolor {
  var %colors = 4 7 8 9 11 12 13
  var %pick = $gettok(%colors, $rand(1, $numtok(%colors, 32)), 32)
  if ($isid) {
    ; Called as $randcolor — return the value
    return %pick
  }
  ; Called as /randcolor — apply to text
  echo -a $chr(3) $+ %pick $+ $1-
}

; Control flow — if/elseif/else, while, goto
alias countdown {
  var %i = $iif($1 isnum, $1, 5)
  while (%i > 0) {
    msg $active %i $+ ...
    dec %i
    ; NOTE: This fires instantly. To add delay, use /timer
  }
  msg $active Go!
}

; Better countdown with timers (non-blocking)
alias countdown2 {
  var %total = $iif($1 isnum, $1, 5)
  var %i = 1
  while (%i <= %total) {
    var %val = $calc(%total - %i + 1)
    .timer 1 %i msg $active %val $+ ...
    inc %i
  }
  .timer 1 $calc(%total + 1) msg $active Go!
}

The timer system is how mIRC handles asynchronous operations. There are no callbacks, no promises, no async/await. You schedule a command to run after N seconds with /timer. Named timers can be started, stopped, and queried. It's primitive but it works for the sequential, event-driven nature of IRC.

Tip: Always use var for local variables in aliases. Global variables (created with set) persist to disk and survive mIRC restarts. Leaving temporary data in global variables is a memory leak that accumulates over weeks. I've seen mIRC instances with vars.ini files exceeding 10 MB because scripts set global variables in tight loops and never unset them.

Remote Events: Reacting to Everything

Remote events are the core of mIRC scripting. They trigger on IRC events: messages, joins, parts, kicks, modes, CTCPs, raw numerics — anything the server sends. The syntax is on [ACCESS]:EVENT:MATCH:TARGET:{code}.

; Respond to a specific message in any channel
on *:TEXT:!help*:#:{
  if ($2 == commands) {
    notice $nick Available commands: !help, !time, !ping, !seen
  }
  elseif ($2 == $null) {
    notice $nick Type !help commands for a list.
  }
}

; Track when users were last seen
on *:TEXT:!seen *:#:{
  var %target = $2
  if ($hget(seen, %target)) {
    var %data = $hget(seen, %target)
    var %time = $gettok(%data, 1, 32)
    var %chan = $gettok(%data, 2, 32)
    var %msg = $gettok(%data, 3-, 32)
    msg $chan %target was last seen $duration($calc($ctime - %time)) ago in %chan saying: %msg
  }
  else {
    msg $chan I haven't seen %target.
  }
}

; Record messages for !seen
on *:TEXT:*:#:{
  hadd -m seen $nick $ctime $chan $1-
}

; Greet users on join (with flood protection)
on *:JOIN:#mychannel:{
  ; Don't greet ourselves
  if ($nick == $me) return
  ; Don't greet if we greeted this user in the last hour
  if ($hget(greeted, $address)) return
  hadd -mu3600 greeted $address 1
  .timer 1 2 msg $chan Welcome to $chan $+ , $nick $+ ! Type !help for info.
}

; Auto-op users by hostmask
on *:JOIN:#:{
  if ($address($nick,2) iswm *!*@trusted.host.com) {
    .timer 1 1 mode $chan +o $nick
  }
}

; React to kicks
on *:KICK:#:{
  ; $knick = the kicked user, $nick = the kicker
  if ($knick == $me) {
    .timer 1 5 join $chan
    echo -s I was kicked from $chan by $nick ( $+ $1- $+ ). Rejoining in 5 seconds.
  }
}

; Raw events — handle any server numeric
raw 332:*:{
  ; 332 = topic reply
  ; $2 = channel, $3- = topic text
  echo -a Topic for $2 $+ : $3-
  haltdef  ; prevent default handling
}

; CTCP handling
ctcp *:VERSION:?:{
  ctcpreply $nick VERSION mIRC Custom Script v2.0
  halt  ; prevent default VERSION reply
}

The access levels system allows scripts to restrict who can trigger events. By default, * means any user. Access levels can be defined in the Users tab of the Script Editor:

; Users list (in Script Editor > Users tab)
; 100:marco!*@*.trusted.net
; 50:*!*@known-isp.com
; 1:*!*@*
;
; Then in remote events:
; on 100:TEXT:!admin*:#:{ ... }  ← only level 100+ users trigger this
; on 50:TEXT:!mod*:#:{ ... }     ← level 50+ users
; on 1:TEXT:!public*:#:{ ... }   ← everyone

Hash Tables: In-Memory Data Storage

Hash tables were added in mIRC 5.8 and they changed what was possible with mIRC scripting. Before hash tables, persistent data meant reading and writing text files with $read() and /write — slow for large datasets. Hash tables provide O(1) key-value lookups in memory.

; Create a hash table with 100 buckets
hmake userdata 100

; Add entries
hadd userdata marco country:Italy joined:2023-01-15 level:admin
hadd userdata sarah country:UK joined:2023-03-20 level:mod

; Retrieve
echo -a Marco's data: $hget(userdata, marco)
; Output: country:Italy joined:2023-01-15 level:admin

; Parse a specific field
alias getfield {
  ; $getfield(table, key, field)
  var %data = $hget($1, $2)
  var %i = 1
  while ($gettok(%data, %i, 32)) {
    var %token = $gettok(%data, %i, 32)
    if ($gettok(%token, 1, 58) == $3) {
      return $gettok(%token, 2-, 58)
    }
    inc %i
  }
  return $null
}
; Usage: echo -a Marco is from $getfield(userdata, marco, country)
; Output: Marco is from Italy

; Save hash table to file
hsave userdata userdata.hsh

; Load hash table from file on startup
on *:START:{
  hmake userdata 100
  if ($isfile(userdata.hsh)) {
    hload userdata userdata.hsh
    echo -s Loaded userdata: $hget(userdata, 0).item items.
  }
}

; Iterate over all entries
alias listusers {
  var %i = 1
  while ($hget(userdata, %i).item) {
    var %key = $hget(userdata, %i).item
    var %val = $hget(userdata, %key)
    echo -a %key => %val
    inc %i
  }
}

; Hash tables with auto-unset (-u flag): entries expire after N seconds
; Great for cooldown timers
hadd -mu300 cooldowns marco 1  ; expires in 300 seconds
; Check if cooldown is active:
; if ($hget(cooldowns, $nick)) { notice $nick Please wait. }
Warning: Hash tables live in memory. A hash table with 100,000 entries will consume significant RAM. mIRC is a 32-bit application on most installations, limiting total memory to about 2 GB. If your script needs to handle very large datasets, save the hash table to disk with hsave regularly and be prepared for data loss if mIRC crashes before saving. There is no transactional safety.

Regular Expressions in mIRC Script

Regex support arrived in mIRC 5.9 via the $regex() identifier and /regex event matching. mIRC uses the PCRE library internally, so the regex flavor is Perl-compatible.

; Basic regex match — returns 1 if match, 0 if not
if ($regex(test123, /^[a-z]+\d+$/)) {
  echo -a Matched!
}

; Capture groups — stored in $regml(N)
var %text = The temperature is 23.5°C today
if ($regex(%text, /(\d+\.?\d*)°([CF])/)) {
  var %temp = $regml(1)    ; 23.5
  var %unit = $regml(2)    ; C
  echo -a Temperature: %temp degrees %unit
}

; Regex in remote events (on *:TEXT pattern)
on *:TEXT:*:#:{
  ; Detect URLs in channel messages
  if ($regex($1-, /https?:\/\/([\w.-]+)/)) {
    var %domain = $regml(1)
    echo -a URL detected from $nick in $chan $+ : %domain
  }
}

; Replace with regex — $regsubex(input, /pattern/, replacement)
alias striphtml {
  return $regsubex($1-, /<[^>]+>/, )
}
; Usage: echo -a $striphtml(Hello world)
; Output: Hello world

; Regex replace with backreferences
alias censor {
  ; Replace bad words with asterisks of same length
  return $regsubex($1-, /\b(badword1|badword2|badword3)\b/gi, $str(*, $len(\1)))
}

; Named capture groups (PCRE)
var %line = [2023-11-12 14:30:05]  Hello
if ($regex(%line, /\[(?P[^\]]+)\]\s<(?P[^>]+)>\s(?P.*)/)) {
  echo -a Date: $regml(date) Nick: $regml(nick) Message: $regml(msg)
}

Sockets: TCP Connections From Script

mIRC's socket API lets scripts open TCP connections to arbitrary hosts. This enables HTTP requests, custom protocol implementations, and inter-bot communication. The API is event-driven: you open a connection, and mIRC fires events for connect, read, close, and error.

; Simple HTTP GET request using mIRC sockets
alias httpget {
  ; Usage: /httpget example.com /api/data
  var %host = $1
  var %path = $iif($2, $2, /)
  sockclose httpget
  sockopen httpget %host 80
  sockmark httpget %host %path
}

on *:SOCKOPEN:httpget:{
  var %host = $gettok($sock(httpget).mark, 1, 32)
  var %path = $gettok($sock(httpget).mark, 2-, 32)
  sockwrite -n httpget GET %path HTTP/1.1
  sockwrite -n httpget Host: %host
  sockwrite -n httpget Connection: close
  sockwrite -n httpget User-Agent: mIRC-Script/1.0
  sockwrite -n httpget
}

on *:SOCKREAD:httpget:{
  var %line
  sockread %line
  ; Skip HTTP headers
  if (%line == $null && !%httpget_body) {
    set %httpget_body 1
    return
  }
  if (%httpget_body) {
    echo -a [HTTP] %line
  }
}

on *:SOCKCLOSE:httpget:{
  unset %httpget_body
  echo -a [HTTP] Connection closed.
}

; TLS socket (HTTPS) — requires mIRC 7.0+
alias httpsget {
  var %host = $1
  var %path = $iif($2, $2, /)
  sockclose httpsget
  sockopen -e httpsget %host 443
  sockmark httpsget %host %path
}

; Socket server — listen for incoming connections
alias startserver {
  socklisten myserver 8888
  echo -a Listening on port 8888
}

on *:SOCKLISTEN:myserver:{
  var %name = client. $+ $ticks
  sockaccept %name
  echo -a Accepted connection: %name
}

on *:SOCKREAD:client.*:{
  var %data
  sockread %data
  echo -a [%data] from $sockname
  sockwrite -n $sockname Echo: %data
}

The socket API is how people built web-based IRC statistics pages, API integrations, and inter-process communication in the mIRC era. It's clunky by modern standards — no async/await, no promises, just event handlers and global state — but it works for single-threaded, event-driven IRC workflows.

Dialogs and DLL Calls

Dialogs give mIRC scripts a graphical interface. You define dialog layouts in script, then display them. The dialog definition language is declarative — you specify controls (buttons, text fields, list boxes, check boxes) with positions and sizes.

; Dialog definition
dialog userinfo {
  title "User Information"
  size -1 -1 200 150
  option dbu

  text "Nickname:", 1, 10 10 50 10
  edit "", 2, 65 10 120 10

  text "Channel:", 3, 10 25 50 10
  edit "", 4, 65 25 120 10

  text "Level:", 5, 10 40 50 10
  combo 6, 65 40 120 60, drop

  check "Auto-op on join", 7, 10 60 100 10
  check "Auto-voice on join", 8, 10 75 100 10

  button "Save", 9, 40 120 50 14, ok
  button "Cancel", 10, 110 120 50 14, cancel
}

; Open the dialog
alias edituser {
  dialog -m userinfo userinfo
  ; Populate combo box
  did -a userinfo 6 User
  did -a userinfo 6 Moderator
  did -a userinfo 6 Admin
}

; Handle dialog events
on *:DIALOG:userinfo:init:*:{
  ; Set defaults when dialog opens
  did -a userinfo 2 $me
  did -a userinfo 4 #mychannel
  did -c userinfo 6 1  ; Select first combo item
}

on *:DIALOG:userinfo:sclick:9:{
  ; Save button clicked
  var %nick = $did(userinfo, 2)
  var %chan = $did(userinfo, 4)
  var %level = $did(userinfo, 6)
  var %autoop = $did(userinfo, 7).state
  var %autovoice = $did(userinfo, 8).state
  echo -a Saved: %nick in %chan as %level (op: %autoop voice: %autovoice)
}

DLL calls extend mIRC beyond what the scripting language alone can do. mIRC can load Windows DLLs and call exported functions. This was the gateway to database access, cryptography, system APIs, and anything else that C code could do.

; DLL call syntax
; $dll(filename.dll, function, parameters)
; The DLL function must conform to mIRC's calling convention:
; int __stdcall function(HWND mWnd, HWND aWnd, char *data, char *parms, BOOL show, BOOL nopause)

; Example: calling a hypothetical database DLL
alias dbquery {
  var %result = $dll(mircdb.dll, query, SELECT nickname FROM users WHERE level > 50)
  echo -a Query result: %result
}

; Example: calling a hashing DLL
alias md5 {
  return $dll(mircrypto.dll, md5, $1-)
}
; Usage: echo -a $md5(Hello World)

; Note: mIRC 7.x added built-in $sha1, $sha256, $md5 identifiers
; so DLLs are less needed for common operations now

The DLL interface made mIRC extensible in ways Khaled probably never anticipated. People wrote DLLs for SQLite access, for HTTP client functionality (before mIRC had built-in sockets), for MP3 playing, for capturing screenshots, and for interfacing with Windows COM objects. The mIRC scripting community was, in a very real sense, building a plugin ecosystem through the DLL interface years before browser extensions or VS Code extensions existed.

The language has its limits. No classes. No modules. No namespaces beyond script file separation. No proper error handling — a bad variable reference doesn't throw an exception, it silently returns empty string. No debugging tools beyond echo statements. No package manager. No dependency resolution. And yet, people built remarkable things with it. The constraint bred creativity. When your only tool is a strange, typeless scripting language embedded in an IRC client, you learn to make it do things it was never intended to do. That's the story of mIRC scripting: a language nobody designed, used by thousands to build things nobody expected.

FAQ

Can I still write mIRC scripts in 2023?

Yes. mIRC is still updated and the scripting engine is fully functional in current versions. The language hasn't changed significantly since the early 2010s, so older tutorials and references still apply. The community is smaller, but #mIRC on EFnet and various scripting forums are still active.

What's the maximum size of a mIRC script?

There's no hard limit on script file size, but mIRC's script editor becomes sluggish with files larger than about 50,000 lines. For large projects, split code across multiple .mrc files and load them all. Global variables and hash tables are shared across loaded scripts.

How do I debug mIRC scripts?

Use echo -a to print variables and state to the active window. Use echo -s to print to the Status window. Use /debug -o filename.log to log all IRC traffic to a file for protocol-level debugging. There is no step debugger, no breakpoints, and no stack traces. Welcome to 1996.

Are mIRC scripts a security risk?

Yes. Scripts can execute system commands via $os, /run, and DLL calls. Malicious scripts disguised as useful tools circulated widely in the 2000s — war scripts with embedded backdoors, supposed protection scripts that actually gave remote access to the author. Never load a script from an untrusted source without reading every line. If it calls /run, $dll, or uses socket connections to unusual hosts, treat it as suspicious.