Capstone: a ghost mod
Let's assemble everything into the mod that defines MultiNostalgia: a ghost. Each player sees translucent stand-ins of everyone else moving through the same level in real time. It uses memory reads, sprite drawing, packing, slots, and a client/server relay — every piece from the tutorial.
This page walks one complete (if simplified) script top to bottom. Real ghost mods like the Mega Man and Super Mario Bros. scripts in the project's deployment-scripts/ folder are bigger — mostly because reverse-engineering a game's exact poses is detailed work — but their skeleton is exactly this.
The plan
Every frame, each client:
- reads the local player's position and facing from RAM,
- packs
(x, y, dir)and sends it to the server, - draws a ghost for every other player it has heard about.
The server stores each player's last update and fans them out on a tick.
Shared declarations
Constants used by both sides go at the top level, outside any on block. Critically, the wire format — the widths arrays — is shared, so the two sides can never disagree about the byte layout.
// Game RAM addresses (Super Mario Bros.).
const PLAYER_X: u32 = 0x0086
const PLAYER_Y: u32 = 0x00CE
const FACING: u32 = 0x0033 // 1 = right, 2 = left (game-specific)
const MAX = 64
// Wire format, defined once so client and server agree.
// fields: x (8b), y (8b), dir (1b)
const FIELDS: i32[] = [8, 8, 1]The client
client {
// --- the ghost sprite + palette (inlined asset) ---
var bytes: u8[] = base64_decode("FRgD…")
var ghost: image = image_from_bitmap_bytes(bytes, 0, 0)
var ghostPal: u32[] = [0x00000000, 0x004488FF, 0x00223388]
// --- what we know about other players, keyed by slot ---
var occupied: bool[MAX]
var otherX: u8[MAX]
var otherY: u8[MAX]
var otherDir: i32[MAX]
event frame(frame_num: i32) {
// 1. read our own state
var x = i32(read_u8(PLAYER_X))
var y = i32(read_u8(PLAYER_Y))
var dir = 0
if read_u8(FACING) == 1 { dir = 1 } // 1 = facing right
// 2. send it, unreliably — 60 Hz, loss is fine
send(pack([x, y, dir], FIELDS), reliable = false)
// 3. draw every ghost we know about
for (var i = 0; i < MAX; i = i + 1) {
if !occupied[i] { continue }
var flip = otherDir[i] == 0 // mirror when facing left
draw_image(ghost, ghostPal, i32(otherX[i]), i32(otherY[i]),
flip_x = flip)
}
}
event receive(data: u8[]) {
// the server prepends the sender's slot (8 bits) to FIELDS
var vals = unpack(data, [8, 8, 8, 1])
var s = vals[0]
otherX[s] = u8(vals[1])
otherY[s] = u8(vals[2])
otherDir[s] = vals[3]
occupied[s] = true
}
}Three things to call out:
dirdrivesflip_x. We send a single bit for facing and mirror the one sprite, instead of shipping two sprites — the palette-and-flip trick from Drawing sprites.- The receive widths are
[8, 8, 8, 1], notFIELDS. The server adds an 8-bit slot in front so the client knows whose update this is. The client's unpack list is the server's pack list. occupied[]gates drawing. We never draw a ghost for a slot we haven't heard from.
The server
server {
var clients: client[MAX]
var occupied: bool[MAX]
var px: u8[MAX]
var py: u8[MAX]
var pdir: i32[MAX]
event connect(c: client, handoff: u8[]) {
var s = slot(c)
clients[s] = c
occupied[s] = true
}
event disconnect(c: client) {
occupied[slot(c)] = false
}
event receive(from: client, data: u8[]) {
var s = slot(from)
var vals = unpack(data, FIELDS) // same FIELDS the client packed
px[s] = u8(vals[0])
py[s] = u8(vals[1])
pdir[s] = vals[2]
}
event tick(tick_num: i32) {
for (var i = 0; i < max_clients(); i = i + 1) {
if !occupied[i] { continue }
for (var j = 0; j < max_clients(); j = j + 1) {
if !occupied[j] || i == j { continue }
// prepend j's slot, then the FIELDS payload
var msg = pack([j, i32(px[j]), i32(py[j]), pdir[j]],
[8, 8, 8, 1])
send(clients[i], msg, reliable = false)
}
}
}
}The server is the canonical relay from the previous chapter: store on receive, fan out on tick, key everything by slot. The only addition is carrying the dir bit through.
Trace one update
Follow a single position change end to end:
- Player A moves right. On A's next frame, the client reads
x=80, y=176, dir=1andsendspack([80, 176, 1], [8, 8, 1])— three bytes. - The server's
event receive(from = A, …)runs, unpacks withFIELDS, and storespx[A]=80, py[A]=176, pdir[A]=1. - On the next
event tick, the server packs[slotA, 80, 176, 1]andsends it to player B (and everyone else). - B's
event receiveunpacks[8, 8, 8, 1], stores the values underslotA, and setsoccupied[slotA] = true. - On B's next frame, the draw loop sees
occupied[slotA]and draws A's ghost at(80, 176), un-flipped becausedir == 1.
That round trip happens ~60 times a second per player, and the result is a living level full of other people.
Where real ghost mods get bigger
This script is honest about positions but naïve about poses — it always draws one sprite. Production ghost mods spend most of their code answering a harder question: which exact animation frame is the remote player on right now? That means reading the game's animation state out of RAM and mapping it to the right sprite — walk cycle, jump, climb, hurt, and so on. The technique is the same read_u8 + lookup you already know; there's just a lot of it, and it's specific to each game.
Browse deployment-scripts/mega_man_2/mega_man_2_ghost.munos in the project to see a fully-developed example — the comments in it are a guided tour of exactly that pose-detection work.
You've finished the tutorial
You can now:
- read and write game memory with
read_*/write_*, - build and draw indexed sprites with palettes, flipping, and clipping,
- pack messages to the bit and send them reliably or not,
- run a slot-keyed client/server relay,
- take input and pause the ROM for your own UI.
That's the whole language in practice. When you need the precise signature of a builtin or the exact rule for some corner of the syntax, head to the Reference →.