Systems
A System is a library utility that wraps no Handle, even when it uses some for its own work. Most of them exist for one problem of multiplayer maps: every client runs the same script, and the game stays in sync only while every client computes the same thing. A value that only one client has, a file on its disk, its clock, the text in its UI, must reach the others through the network before it changes game state. The sync System does that; the others build on it or feed it.
| System | Exports | What for |
|---|---|---|
| Sync | SyncRequest, SyncStatus | One client's data, made known to every client |
| Host | Host | One player elected the same on every client |
| File | File | Text files in the player's CustomMapData folder |
| Binary | BinaryWriter, BinaryReader, base64Encode, base64Decode | Values packed into a string, and back |
| Game time | getElapsedTime | Seconds since the game started, the same everywhere |
The collections a multiplayer map iterates, SyncedMap, SyncedSet, HandleMap and HandleSet, are Systems too; they are covered with the Guards (The four collections).
Sync
A SyncRequest sends one player's data to every client. Every client runs the same code and starts the same request; only the sender's client sends its data, and every client receives it and resolves the request's Promise with it, at the same moment of the game:
import { File, Init, MapPlayer, SyncRequest } from "reforged-ts";
async function loadSaveCode(sender: MapPlayer): Promise<void> {
try {
// Each client reads its own disk; only the sender's contents are sent.
const response = await SyncRequest.send(
sender,
File.read("savecode.txt") ?? "",
{ timeout: 10 },
);
print(`${response.from.name} loaded: ${response.data}`);
} catch (reason) {
print(String(reason));
}
}
Init.onGameStart(() => {
const sender = MapPlayer.fromIndex(0);
if (sender !== undefined) {
void loadSaveCode(sender);
}
});
- Every client, same order. A request's id is its rank among the requests created, so create and start requests on every client, in the same order. A request created on one client only (inside
MapPlayer.runLocal, in a local-player branch) shifts that client's ids, and its later requests stop matching the others'. - The response.
datais the sender's data,fromthe sender,timethe game time when the last packet arrived. The data given on the other clients is ignored. - Failure. The
Promiserejects with a message naming the request on a timeout (options.timeout, in seconds; none by default), oncancel(), and when the game refused to send a packet.statustells where a request stands. - Size. The data is split into packets of 244 bytes, up to 65,535 of them, and joined back. The game cuts a packet at a zero byte, so the data must hold none: encode binary data with
base64Encodefirst.startthrows on the sender's client for data with a zero byte or too long. - The prefix. The System's packets carry the sync prefix
rts. A Map project using the rawBlzSendSyncDataorPlayerEvents.syncDatapicks another prefix.
SyncRequest.send(from, data, options) creates and starts a request at once; new SyncRequest(from, options) then start(data) keeps the request for cancel() or status.
Host
Host.detectHost() elects one player, the same on every client, so a Map project can give one player a role (the host's settings dialog, a game mode vote) without desyncing:
import { Host, Init, MapPlayer } from "reforged-ts";
async function greetHost(): Promise<void> {
try {
const host = await Host.detectHost({ timeout: 15 });
MapPlayer.runLocal(host, () => {
print("You are the host.");
});
} catch (reason) {
print(String(reason));
}
}
Init.onGameStart(() => {
void greetHost();
});
The heuristic: the host created the lobby, so the host's client sat in the lobby the longest. Each client measures its own lobby time with os.clock, from the editor's config to the gameStart stage, and sends it through SyncRequest; the longest time wins, and a tie goes to the lowest slot. A player who leaves before answering is dropped, and the timeout (ten seconds by default) settles the election with the times received. Every call returns the same Promise, which rejects only when no time arrived; Host.host is the elected player once it resolved.
The heuristic was verified in game on 3.0.0.24268 (#131): config runs when the map loads in the lobby, and os.clock tracks wall time while the client sits there. The lobby time runs to the gameStart stage, so it also counts the countdown and the loading screen; that part is common to every client and cancels out in the comparison. With two clients, the lobby creator was elected and both clients agreed, also when the creator sat in slot 1 and the other player in slot 0.
File
File reads and writes text files through the game's preload files, the only file access a map has (the game's Lua has no io):
import { File, Init } from "reforged-ts";
Init.onGameStart(() => {
File.write("settings.txt", "volume=80");
const contents = File.read("settings.txt");
if (contents !== undefined) {
print(contents);
}
});
- Files live under
Documents\Warcraft III\CustomMapDataon each player's machine, with the extension.txtor.pld. - A file cannot be deleted; write it empty instead.
File.writeadds the codeFile.readneeds to read the file back.File.writeRaw(name, contents)leaves it out, for a file meant to be read outside the map; the game's preload code still surrounds the contents.File.readreturnsundefinedwhen the file cannot be read.
Every client has its own disk. File.read returns this client's file, which differs between players: a value read from a file is local, like a value from an @async Native. Send it through SyncRequest before it changes game state, as the save-code example above does.
Binary
BinaryWriter packs numbers and strings into one binary string, and BinaryReader reads them back in the same order. Each value has a fixed width, big-endian: Int8 to Int32 and UInt8 to UInt32, Float (four bytes) and Double (eight, lossless), and strings with a two-byte length. A value outside its width's range throws at the write:
import {
base64Encode,
BinaryReader,
BinaryWriter,
base64Decode,
} from "reforged-ts";
const writer = new BinaryWriter();
writer.writeUInt8(3); // level
writer.writeUInt32(125000); // gold
writer.writeString("Arthas");
// Binary data can hold zero bytes: encode it before a sync or a file.
const encoded = base64Encode(writer.toString());
const reader = new BinaryReader(base64Decode(encoded));
print(reader.readUInt8()); // 3
print(reader.readUInt32()); // 125000
print(reader.readString()); // Arthas
base64Encode and base64Decode turn any byte string into text and back (RFC 4648, with padding): the form a sync packet and a file need.
Integers in the game's Lua are 32-bit (Runtime facts). readUInt32 still returns the full range, 0 to 2^32 - 1: a value above 2^31 - 1 comes back as a float, equal to what was written.
Game time
getElapsedTime() returns the seconds of game time since the gameStart stage, 0 before it. A periodic Timer keeps it, so it pauses with the game and is the same on every client: use it for timestamps in game state, never os.clock or os.time (Timers).
Lint rules
no-async-value-as-state: a value that differs between clients (an@asyncNative,MapPlayer.fromLocal(),os.clock) flowing into game state; share it throughSyncRequestfirst.no-unordered-iteration: iteration that compiles topairs; use the synced collections.no-game-state-in-local-branch: game state changed for one client only.