Source Code
Scripts
scripts/
├ commands/ player and staff chat commands
├ features/ PQs and minigames like KerningPQ, Monster Carnival
├ maps/ Maps with unique interactions like Free Market
├ npc/ NPC conversations
├ portal/ portals with special behavior
├ quests/ 680 q<id>s.js starts a quest, q<id>e.js ends it
└ reactor/ breakable and interactive objectsMost of the game's content lives in JavaScript instead of Java. NPC conversations, quests, portals, reactors, chat commands and whole minigames are scripts the server runs through Java's Nashorn engine, and they call straight into the server's Java classes.
That keeps the Java side focused on the engine while content gets written quickly. Quests alone are almost 700 scripts. GM commands can reload scripts, quests, shops and drops on a live server, so a typo in an NPC's dialogue gets fixed without restarting anything.
// compiled once, kept in a cache and shared by every player
script = cache[name]
if script is missing or the file changed since it was compiled:
script = compile(file) // syntax errors are caught here
cache[name] = script
// each player only gets a small instance of their own
instance = script.newInstance() // e.g. one Monster Carnival match
instance.vars = {
teams: [red, blue],
startTime: now(),
}
script.invoke("register", player)Scripts are compiled once and kept in memory, so every player shares the same compiled copy. The CPU doesn't have to read and parse a file each time someone talks to an NPC or walks through a portal, and a script is only compiled again when its file actually changes.
When a player talks to an NPC or starts a minigame, all they get is a small object of their own. It holds only the data unique to that player or match, like the teams and CP in a Monster Carnival game, while the logic stays shared.
Compiling up front also means mistakes show up early. A syntax error fails the compile and lands in the server log before the script ever runs for a player, instead of a player finding it halfway through a quest.
Resources
[server]
Name = GalaxyLand
LoginPort = 8484
ChannelPort = 7575
WorldCount = 1
[world_0]
ModExperience = 429.0
ModMoney = 69.0
ModDropChance = 1.0
ChannelCount = 2
Message = Hello World!Anything that changes between servers or setups sits in resources instead of the code. server.ini holds the server address, world count, and each world's experience, meso and drop rates, channel count, so running a second world or a test server with boosted rates is a config change.
The rates are floats rather than integers. Most servers store them as whole numbers, which locks them into steps like 2x or 3x. With floats, a world can run at 2.5x experience, and bonuses can stack on top in small amounts.
Custom quests live there too. The game's own quests come from its data files, but a custom quest line is written as plain data: a name, a description, and the items to take or give at each step.
[
{
"id": -1, // negative: never clashes with the game's own quests
"name": "Wedding Ring",
"description": "Getting married isn't as easy as you think...",
"start": {
"items": [
{ "id": 4031367, "quantity": -1 }, // Proofs of Love
{ "id": 4031368, "quantity": -1 },
...
]
}
},
...
]Server scaling
// two separate programs, started on their own
serenity-login.jar LoginServer port 8484
serenity-channel.jar ChannelServer port 7575+
// they talk over their own connection
channel -> login:
SERVER_STATUS_UPDATE "The server is now available. You may login."
SERVER_NOTICE forward a notice to everyone at login
WORLD_POPULATION_REQUEST players per channel, for the channel list
PLAYER_STORAGE_UNLOAD a player's data was saved and unloadedThe login and channel servers are separate programs. They share a core module but run on their own and talk to each other over a network connection, sending status updates, notices and player counts.
The biggest win is restarts. The channel server can go down for an update while the login server stays up, so players keep their game clients open at the login screen. When the channel server comes back, the login server tells everyone it's available and they log straight back in. Since they only talk over the network, the two don't have to run on the same machine either.
Networking
Handshake
// sent as soon as the client connects, unencrypted
outPacket.encode2(14) // length of what follows
outPacket.encode2(83) // game version
outPacket.encodeStr("1") // patch version
outPacket.encodeBuffer(recvIV) // 4 bytes, client's send IV
outPacket.encodeBuffer(sendIV) // 4 bytes, client's receive IV
outPacket.encode1(8) // locale
// everything after this is AES encryptedThe handshake is the only packet that isn't encrypted, and the server sends it first, the moment a client connects. It tells the client which game version and locale the server expects, so an outdated or wrong-region client stops right there.
It also carries two 4-byte IVs (initialization vectors), one for each direction. The last byte of each is random per connection, so no two sessions encrypt the same way. From here on, both sides encrypt every packet with AES using those IVs and change them after each packet, which keeps the two sides in lockstep.
Heartbeat
// TCP level: the OS probes idle connections
socket.SO_KEEPALIVE = true
// game level: ping when the connection goes quiet
idle after 60s without reading or 30s without writing:
user.pingSentAt = now()
send(KEEP_ALIVE)
on KEEP_ALIVE reply:
user.latency = now() - user.pingSentAtA heartbeat (ping-pong, or keep alive) checks that the other side is still there. TCP has its own version, SO_KEEPALIVE, which I turn on for every client socket so the operating system quietly probes connections that have gone idle. The catch is that the OS waits a long time before probing, around two hours by default, which is far too slow for a game.
So the server also runs its own heartbeat. When a connection goes quiet for 60 seconds without receiving or 30 seconds without sending, it sends a small keep alive packet and the client answers right away. Timing the round trip gives each player's latency for free. This is nice for those times when you AFK overnight and want to resume your game the next day just to find out you've been disconnected as soon as you interact with the game.
Sockets and server transfers
Players never really move between servers. When someone picks a character, the login server flags the account as ServerTransfer and hands the client the channel's address, port and player ID. The client drops the login connection and opens a new one to the channel.
// first packet received on the new socket
playerID = inPacket.decode4()
if account.loginState != SERVER_TRANSFER:
// never went through the login server
disconnect()
return
// disconnect any other session using this account
account.loginState = LOGGED_IN
loadPlayer(playerID)The channel only lets them in if that flag is still set, so nobody can skip the login server, and it kicks any duplicate session before loading the character. Changing channels uses the same handoff. One case I had to handle separately was players on the same network as the server, who need the local address instead of the public one (hairpin NAT).
Packets
outPacket = new OutPacket(0x00FF) // 2-byte opcode
outPacket.encode1(1) // 01
outPacket.encode2(500) // F4 01
outPacket.encode4(1234) // D2 04 00 00
outPacket.encodeStr("hello") // 05 00 68 65 6C 6C 6F
send(outPacket)
// the receiver reads in the same order and widths
flag = inPacket.decode1()
value = inPacket.decode2()
id = inPacket.decode4()
text = inPacket.decodeStr()Packets are just fields in a fixed order after a 2-byte opcode, with nothing labeling them. The client reads and writes by width (Decode1, Decode2, Decode4, DecodeStr, DecodeBuffer), and my server has to mirror every field exactly or everything after it becomes misaligned and the game crashes.
function encode4(value):
outPacket.append(value & 0xFF) // lowest byte first
outPacket.append((value >> 8) & 0xFF)
outPacket.append((value >> 16) & 0xFF)
outPacket.append((value >> 24) & 0xFF) // highest byte lastEverything is little-endian since the client is x86. Java is big-endian by default, so my writer splits each value into bytes by hand.
NEUTRAL = 0 // 1 << 0 = 000001
FIRE = 1 // 1 << 1 = 000010
ICE = 2 // 1 << 2 = 000100
LIGHTNING = 3 // 1 << 3 = 001000
POISON = 4 // 1 << 4 = 010000
HOLY = 5 // 1 << 5 = 100000
// a monster weak to fire and ice
weaknesses = (1 << FIRE) | (1 << ICE)
000010 fire
| 000100 ice
------
000110 = 6Bitmasks show up all over the protocol. The easiest way I can explain them is with elements: give each one a bit (neutral 0, fire 1, ice 2, and so on), and a monster weak to fire and ice is just 000110, the number 6.
000110 weaknesses
& 000010 1 << FIRE
------
000010 non-zero -> weak to fire
if weaknesses & (1 << FIRE):
damage *= 1.5Checking a weakness is one AND. Only the bit both sides share survives, so a fire attack against that monster leaves the fire bit on and gets bonus damage.
000110 weaknesses
& 100000 1 << HOLY
------
000000 zero -> not weak to holy
if weaknesses & (1 << HOLY): // false
damage *= 1.5 // skipped, normal damageA holy attack has no bit in common with the fire and ice bitmask, so the AND comes out to zero and the attack deals normal damage.
000110 weaknesses
| 010000 1 << POISON
------
010110 poison added
weaknesses |= (1 << POISON)OR adds a flag. Any bit that's on in either side stays on, so poison joins fire and ice without changing them. Adding a flag that's already set does nothing.
000100 1 << ICE
~ ------
111011 every bit except ice
010110 weaknesses
& 111011 ~(1 << ICE)
------
010010 ice removed
weaknesses &= ~(1 << ICE)Removing a flag takes two steps. ~ inverts the ice bit into a mask of every bit except ice, then ANDing with it clears ice and keeps everything else as it was.
010010 weaknesses
^ 000010 1 << FIRE
------
010000 fire was on -> now off
010000 weaknesses
^ 000010 1 << FIRE
------
010010 run again -> back on
weaknesses ^= (1 << FIRE)XOR flips a flag. If fire is on it turns off, and running the same line again turns it back on, without needing to check its current state first. Not really useful in most cases because 99% of the time we need to know the state of the bit.
mask = [0, 0, 0, 0] // 4 ints = 128 bits
for each buff in activeBuffs:
index = buff.position / 32 // which int
bit = buff.position % 32 // which bit inside that int
mask[index] |= 1 << bit
for i from 3 down to 0: // highest int first
outPacket.encode4(mask[i])
// client side: the same AND, on the right int
function hasBuff(mask, buff):
return (mask[buff.position / 32] & (1 << (buff.position % 32))) != 0Buffs work the same way, just bigger. There are more than 32 of them, so the mask is four ints (128 bits), and each buff's position picks the int and the bit inside it.
Packet handlers
opcode = inPacket.decode2()
switch opcode:
case CHAR_INFO_REQUEST:
inPacket.skip(4)
PlayerHandler.charInfoRequest(inPacket.decode4(), ...)
break
case USE_ITEM:
InventoryHandler.useItem(inPacket, ...)
break
// ...200+ more cases
default:
print "unhandled opcode"One old, used-to-be-popular source, Mushy, routes packets through one switch with over 200 cases. One missing break runs the next case, and every new packet means editing that same method. Not to mention the monstrosity of a method this creates at almost 1000 lines long.
// built once at startup
handlers = array sized to the largest opcode
handlers[PLAYER_LOGIN] = PlayerLoginEvent
handlers[PLAYER_MOVE] = PlayerMoveEvent
handlers[CHANGE_CHANNEL] = ChannelChangeEvent
// on every packet
opcode = inPacket.decode2()
handler = new handlers[opcode]()
handler.processData(inPacket) // read the packet
if not handler.cancelled:
handler.onEvent() // act on itI went with a table instead. Each opcode maps to a handler class at startup, so finding one is a single array lookup. Every handler reads the packet in processData and acts in onEvent, so a bad packet can be cancelled before anything changes. The login and channel servers build separate tables, so a packet sent to the wrong server goes nowhere.
abstract class AbstractPlayerAttackEvent extends PacketEvent:
processData(inPacket): // same for every attack type
// parse the packet to get skill used, mobs damaged and damage lines
onEvent():
broadcast getAttackPacket() // show other players the attack
onAttack() // apply the hits to monsters
abstract getAttackPacket()
class PlayerMeleeAttackEvent extends AbstractPlayerAttackEvent:
getAttackPacket(): // generate melee attack packet
onAttack(): // process melee unique skills such as combo counter, energy charge
class PlayerShootAttackEvent extends AbstractPlayerAttackEvent
class PlayerMagicAttackEvent extends AbstractPlayerAttackEventHandlers being classes meant similar packets could share code. Melee, ranged and magic attacks are nearly identical, so AbstractPlayerAttackEvent reads them once and each subclass only adds what's different.
// same movement format, shared helper instead of a parent class
PlayerMoveEvent: movements = MovePath.decode(inPacket)
MonsterMoveEvent: movements = MovePath.decode(inPacket)
PetMoveEvent: movements = MovePath.decode(inPacket)
SummonedMoveEvent: movements = MovePath.decode(inPacket)
// then each handler moves its own entity
MovePath.updatePosition(movements, entity)Movement is similar but at the time, I wanted to keep it similar to the reverse-engineered client code. Players, monsters, pets and summons share the same move path format, so they all call a static method within MovePath.
// shared (HeavenMS): one instance for every player
handlers[opcode] = new UseItemHandler() // created once
handlers[opcode].handlePacket(inPacket, client)
// only local variables are safe here
// per-packet (mine): a new instance for every packet
handler = new handlers[opcode]() // created per packet
handler.processData(inPacket)
// fields like skill, targets and damage
// belong to this packet onlyI also instantiate a new handler for every packet. HeavenMS shares one instance of each handler across all players, so everything has to stay in local variables. With one handler per packet, I can store what I read in fields and change it freely without affecting anyone else, which is also what makes script proxies possible.
handler = new MonsterCarnivalEvent()
handler.processData(inPacket)
// hand the handler to every script that registered its name
for each script in player.activeScripts:
if script proxies handler.name:
script[handler.name](handler) // -> carnival_game.js below
if handler.cancelled:
return // the script said no
handler.onEvent() // the script said yes// register which handlers this script wants to see
setPacketEventProxies(
"MonsterCarnivalEvent",
"PlayerMeleeAttackEvent",
"PlayerShootAttackEvent",
"PlayerMagicAttackEvent"
)
// receives the handler from step 1: check the summon
function MonsterCarnivalEvent(instance, event):
if field.monsterCount >= field.monsterCapacity:
send "too many monsters"
event.setCancelled(true)
return
if member.cp < requiredCP:
send "not enough CP"
event.setCancelled(true)
return
member.cp -= requiredCP // allowed, step 1 runs onEvent
// same idea for attacks: award CP on a kill
function PlayerMeleeAttackEvent(instance, event):
for each monster hit in event.damage:
if the hit kills it:
member.cp += monster.cpScripts can hook a handler by name. Once a packet is read, the server hands the handler to every script that asked for it before the handler acts, and the script can change its fields or cancel it.
Monster Carnival is the biggest example. Its script registers for the carnival and attack handlers. When a player tries to summon, the server passes the carnival handler to the script, which checks the summon limit and CP cost. If something doesn't add up, it cancels the packet and the Java handler never runs. Otherwise it takes the CP and lets the server carry on.
The attack handlers go through the same path, and the script hands out CP whenever a hit kills a monster. None of the minigame's rules live in the Java handlers, so the whole thing stays in one script.
Reverse Engineering
Client editing & DLL proxies
decimal 199999
double (hex) 0x410869F800000000
bytes in file 00 00 00 00 F8 69 08 41 (little-endian)My first client edits were byte patches saved straight into the executable with OllyDbg. Changing the damage cap meant finding its file offset and converting the bytes to and from a double by hand.
// "You can't do it right now. Catch your breath and then try again."
// each check is 'cmp cooldown, 0; jle allowed'
// changing jle to jmp short (0xEB) always allows the action
write 0xEB at 0x004F56B0 // dragging inventory items
write 0xEB at 0x00A04E28 // entering the Cash Shop
write 0xEB at 0x00A240EA // summoning a pet
// UFJ: NOP 6 bytes
fill 6 bytes at 0x0096BF59 with 0x90Cheats were a few NOPs or a flipped jump, like removing the "You can't do it right now" cooldown or UFJ (unlimited flash jump). Every patch overwrote the original bytes, so undoing one meant keeping backups or remembering what used to be there.
The DLL is where client editing ended up for me. Once my code runs inside the client, anything is possible: patching bytes, replacing functions, new UI, custom packets, overlays. The executable is never touched, and since the source lives in Git next to the server, every change has history and undoing one is a revert.
To get it loaded, I piggyback on nmconew.dll, a messenger library the client already loads at startup. I rename the original to nmconew2.dll and give mine its name, so my hooks run before the game finishes starting.
// my DLL, loaded by the client as nmconew.dll
original = loadLibrary("nmconew2.dll") // the renamed real DLL
if original is missing:
show error and exit
realCallNMFunc = getProcAddress(original, "NMCO_CallNMFunc")
realMemoryFree = getProcAddress(original, "NMCO_MemoryFree")
// exported with the same names the client expects
export NMCO_CallNMFunc: jmp realCallNMFunc
export NMCO_MemoryFree: jmp realMemoryFree
on DLL load:
apply every hook and patchThe client still calls the real exports, so my DLL loads nmconew2.dll and forwards each export with a single jmp. If the original is missing, the game exits with a clear error instead of crashing later.
on DLL load:
if config.discord is enabled:
start thread updatePresence
function updatePresence():
discord.initialize(appID)
while presence is enabled:
player = client.localPlayer // read from client memory
field = client.currentField
presence.details = player.name + " Lv. " + player.level
presence.state = mapName(field.id)
presence.largeImage = "maplestory"
discord.update(presence)
sleep(10 seconds)Discord Rich Presence
Running inside the client also means Discord Rich Presence can show more than "Playing MapleStory". The DLL can read the character's name, level and map straight from the game's memory and update the status as the player moves around.
Code caves
// moves the storage panel for the widened game window
cave storageWall:
push 144 // new position values
push 375
jmp 0x00469436 // 0x0046942F + 7
// the overwritten instructions span 7 bytes:
// 5 for the jmp, 2 left over become NOPs
putCodeCave(0x0046942F, storageWall, 7)Code caves are how I change a single instruction. I overwrite it with a 5-byte jmp into my DLL, run the new logic plus the instructions I replaced, then jump back. Any leftover bytes become NOPs so the CPU never lands mid-instruction.
function putCodeCave(address, cave, sizeNOP):
fill sizeNOP bytes at address with 0x90 // NOP the old instructions
write 0xE9 at address // jmp rel32
write (cave - address - 5) at address + 1 // 4-byte offsetpatch address 0x0046942F
next instruction 0x0046942F + 5 = 0x00469434
code cave 0x10001000
offset 0x10001000 - 0x00469434 = 0x0FB97BCC
bytes written E9 CC 7B B9 0F 90 90
jmp offset NOP NOPThe jump stores an offset, not an address, counted from the end of the jump. So it's destination - address - 5, written little-endian after the E9.
Microsoft Detours
For anything bigger than a few instructions I use Microsoft Detours. It redirects a whole function to my own version and still lets me call the original, all in plain C++ instead of inline assembly.
original = address of CreateWindowExA
function hook(className, windowName, style, ...):
if className == "MapleStoryClass":
windowName = customWindowName // custom window title
style |= WS_MINIMIZEBOX // enable the minimize button
return original(className, windowName, style, ...)
// redirect CreateWindowExA to hook;
// 'original' still calls the real function
detour(original, hook)A simple one is my hook on CreateWindowExA: give the game window a custom title and a minimize button, then pass everything on to the real function.
Cheats
// a plain value in the client's memory
damage capacity double at 0x00AFE8A0
read 199999 // the client's damage cap
write 999999 // client now calculates damage up to the new cap
// no file offsets, no hex conversion:
// Cheat Engine reads and writes the double directlyCheat Engine let me experiment on the live game. The damage cap I used to dig out of the executable is just one number in my cheat table now. Patches live as scripts with an [enable] and [disable] half, so I can toggle them with a checkbox. My table has dozens of them across stats, combat, utility, testing and fun.
It works as a debugger too. I can change a value like the breath timer or the chat line limit live to see what it does, unlock GM-only client features, or force a map to act like another map type to reproduce a bug.
Writing cheats also showed me how to stop them. With fast attack or mob count edits turned on, I could see exactly what the server receives, and my attack checks came from that: attack timing, learned skills, and monster and hit counts against the skill's real limits.
// cheat table: toggled by hand while testing
fly [enable] jmp 0x009CB8DD at 0x009CB8CE
swim [enable] jmp 0x0070470A at 0x007046BB
// DLL: the same addresses, now a permanent feature
Game::SetFly(enabled):
write (enabled ? jmp short : je) at 0x009CB8CE
Game::SetSwim(enabled):
write (enabled ? jmp short : jne) at 0x007046BBQuality of life changes start there too. I try them on the live client first, and if they feel right, the same patch moves into the DLL. My fly and swim toggles patch the exact instructions their cheat table entries started from.
// a normal NPC click sends one small packet
outPacket = new OutPacket(NPC_TALK)
outPacket.encode4(npc.objectID) // which NPC on the map
send(outPacket)
// nothing in it says where the player is standing,
// so the same packet can be sent from the bottom
// of a jump quest map without climbing it// NpcTalkEvent
objectID = inPacket.decode4()
npc = player.field.getNpc(objectID)
if npc is null:
return // NPC isn't on this map
if distance(player.position, npc.position) > TALK_RANGE:
cancel // too far to have clicked it
alert staff
start npc conversationSome cheats don't touch memory at all. They just send the packet an action would have sent. Talking to an NPC only sends its ID, so a player could finish a jump quest like John's Pink Flower Basket by talking to the NPC at the end from the bottom of the map.
The fix belongs on the server. It knows where the player and the NPC are standing, so it can refuse a conversation started from across the map.
// both players pick a character at the same time
attacker's account -> login_state = SERVER_TRANSFER
victim's account -> login_state = SERVER_TRANSFER
// attacker reaches the channel first,
// sending the victim's player ID instead of their own
outPacket = new OutPacket(PLAYER_LOGIN)
outPacket.encode4(victimPlayerID)
send(outPacket)
// channel only checks the state of the account that owns the ID
if account(victimPlayerID).loginState == SERVER_TRANSFER:
load the victim's character on the attacker's connection// login server: remember who started the transfer
account.lastKnownIP = client.ip
// channel server: PlayerLoginEvent
if client.ip != account(playerID).lastKnownIP:
disconnect()
returnRemote hack
The biggest security exploit I've seen. When a player picks a character, their account sits in the ServerTransfer state until the channel server sees their login packet, and that packet only carries a player ID. Player IDs aren't secret, since the client sees them for everyone on the map.
If an attacker times their own transfer with another player's, both accounts are in ServerTransfer at once. Sending the channel a login packet with the victim's player ID passes the check, because the victim's account really is mid-transfer, and the attacker's connection ends up controlling the victim's character.
The easiest fix is checking the IP. The login server records the last known IP of the account that started the transfer, and the channel refuses any login for that character coming from a different address.
Updating the DirectX renderer
The client renders with DirectX 8, which overlay libraries don't support. A d3d8to9 wrapper dropped in as d3d8.dll translates everything to DirectX 9 without touching the client.
hook Direct3DCreate9():
d3d = original()
detour(d3d.vtable[16], hookCreateDevice) // CreateDevice
return d3d
hook CreateDevice(...):
device = original(...)
detour(device.vtable[17], hookPresent) // Present
detour(device.vtable[16], hookReset) // Reset
return device
hook Present(device):
if ImGui is not initialized:
initialize ImGui with device
if overlay is visible:
start ImGui frame
draw console and field info
render ImGui on top of the game
return original(device) // show the frame
hook Reset(device):
release ImGui resources
result = original(device)
recreate ImGui resources
return resultFrom there I set up ImGui with a chain of detours: Direct3DCreate9 leads to CreateDevice, which leads to the device's Present and Reset.
ImGui draws in Present right before each frame is shown, and Reset rebuilds its resources when the window changes. I also took over the window's input handler so the overlay gets mouse and keyboard input while it's open.
Database
Rollbacks
// website main page, on every request
SELECT COUNT(*) FROM accounts WHERE login_state == 1
// attacker spams the page
-> every connection the database allows is busy
// meanwhile, on the game server
player logs out -> save(player)
connection = database.getConnection() // waits, then times out
save fails
// next login loads the last save that worked
-> the player is rolled backA rollback is when a player logs in and finds their progress gone, because the save that should have stored it never made it to the database. One possible cause I ran into wasn't the game at all. The website's main page showed the online player count by reading the login state in the accounts table on every request. Someone spamming the page could use up every connection the database allows, and any player save that happened at the same time failed.
The same thing can happen in game if someone finds a way to trigger a lot of heavy operations at once, like forcing saves of whole characters with their skills, quests and full inventories. The fix is the same either way: anything a player can trigger over and over shouldn't be able to take connections away from saving.
Remote databases
// example: one character save = ~300 queries
database on the same LAN
~0.5 ms per query x 300 = ~0.15 seconds
external hosted database
~40 ms per query x 300 = ~12 secondsHosting the database with an external service sounds convenient, but every query has to travel to it and back. One query barely matters. A character save is hundreds of them, and that delay adds up across every save, login and lookup.
The best setup I've found is separate machines for separate services, all on the same LAN. The game server, database and website each get their own hardware, and they still talk to each other with almost no delay.
Table relationships
accounts id PRIMARY KEY
└ players account_id -> accounts.id
├ player_skills player_id -> players.id ON DELETE CASCADE
├ player_keybinds player_id -> players.id ON DELETE CASCADE
└ player_quests player_id -> players.id ON DELETE CASCADE
└ player_quests_mob_progress
quest_id -> player_quests.uid ON DELETE CASCADE
DELETE FROM players WHERE id = 1
-> its skills, keybinds, quests and quest progress go with itEvery table has a primary key that identifies each row, like a player's id. Other tables point back to it with a foreign key: a row in player_skills holds the player_id it belongs to, and the database refuses a skill for a player that doesn't exist.
Foreign keys also decide what happens when the row they point to changes. With ON DELETE CASCADE, deleting a character deletes its skills, keybinds and quests, and deleting a quest deletes its monster kill progress, so nothing is left behind. ON UPDATE RESTRICT does the opposite and refuses to change a player's ID while other rows still point to it. Players don't cascade from accounts, so an account can't be deleted while it still has characters.
Table sizes vs data partitioning
// one row per player in each table, joined by player_id
players id, account_id, gm_level, ... 9 columns
player_basic_stats username, level, job_id, mesos, ... 29 columns
player_extra_stats rebirths, fishing_level, pvp_kills, ... 17 columns
player_points jump_quest_points, boss_points, ... 8 columns
// a new point system only widens the table it belongs to
// V1.5__player_rush.sql
ALTER TABLE player_extra_stats
ADD COLUMN rush_points integer NOT NULL DEFAULT 0;
ALTER TABLE player_extra_stats
ADD COLUMN rush_entries integer NOT NULL DEFAULT 0;Custom servers keep adding systems: jump quest points, boss points, fishing levels, PvP kills, rebirths. Every one of them is another column, and if they all lived on the players table, it would keep growing wider with each feature until a single character was a row of 60+ columns that had to be rewritten on every save.
So a character is split by purpose. player_basic_stats holds the core stats every character has, player_extra_stats holds the custom progression, and player_points holds the point currencies. Each table stays a manageable size. Any table can be skipped during a save if the developer decides to implement it. A player with 500 skills, 500 quests and 500 items who logs in for 5 minutes to talk to some friends doesn't need a full rewrite to the database.
Website integration
// open a plain TCP connection to the login server
socket = fsockopen(LOGIN_SERVER_IP, LOGIN_PORT, timeout = 1)
if socket:
status = "online" // the server answered (with its handshake)
fclose(socket)
else:
status = "offline"
// on shared hosting fsockopen is disabled,
// so this always fails and the server always looks offlineMost server websites show whether the game is online by opening a connection to the login server with PHP's fsockopen. If the connection goes through, the server is up, since it answers every new connection with its handshake right away.
The catch is shared hosting. Cheap hosting plans usually disable fsockopen and outgoing connections in general, so the check always fails and the status shows offline even when the game is running.
// website: called by the voting site (e.g. GTop100) after a vote
if vote.successful:
socket = fsockopen(GAME_SERVER_IP, PORT)
outPacket = new OutPacket(VOTE_REWARD)
outPacket.encodeStr(vote.username)
fwrite(socket, outPacket)
fclose(socket)
// game server
on VOTE_REWARD:
if sender.ip != WEBSITE_IP:
disconnect() // only the website may send this
return
username = inPacket.decodeStr()
user = findOnlineUser(username)
if user:
give the reward now and send a notice
else:
save the vote points for their next loginWhen it's available, fsockopen can do more than check status. Voting sites like GTop100 notify the website after someone votes. Instead of only writing that to the database and making players claim it with an in-game command, the website can open a socket to the game server and send it a packet saying who voted.
The server then hands out the reward live, with a notice, while the player is still in game. That packet has to be locked down to the website's IP, otherwise anyone could send it and give themselves rewards. It could also just be a little nudge to the server telling it to check recent votes, that way nobody receives rewards without voting.
Registration botting
// register.php
token = POST["g-recaptcha-response"] // added to the form by Google's widget
result = POST https://www.google.com/recaptcha/api/siteverify
secret = RECAPTCHA_SECRET
response = token
if not result.success:
show "please complete the captcha"
return
create the accountWithout any protection, a registration form can be filled out by a script thousands of times. That floods the database with junk accounts and gives people an endless supply of throwaway accounts for alts and abuse.
It isn't a sophisticated attack, and the fix isn't either. Google reCAPTCHA adds a check to the form, and the website confirms the result with Google before creating the account. It takes a few lines to set up, which is why I think every server's website should have it.
DDoS attacks
Private servers get attacked constantly, usually by people from rival servers or players who were banned. The website is the easiest target: every request it handles can hit the database, so a flood of fake traffic slows the site down and, as with rollbacks, can starve the game server of database connections. Most of that traffic never needs to reach the website at all, which is where Cloudflare comes in.
Cloudflare
# don't let crawlers index or hammer the site
User-agent: *
Disallow: /With the domain behind Cloudflare, every visitor goes through Cloudflare first and the server's real IP stays hidden. The first thing I set up was keeping bots out. A robots.txt that disallows everything stops well-behaved crawlers, and Cloudflare's bot protection handles the ones that ignore it.
# block regions the server doesn't serve
(ip.src.country in {"CN" "RU"})
-> Block
# hosting providers commonly used by VPNs and proxies
# (e.g. M247, DigitalOcean, OVH, Hetzner, Vultr)
(ip.src.asnum in {9009 14061 16276 24940 20473})
-> BlockCloudflare also knows the country and ASN of every visitor's IP. An ASN identifies the network an IP belongs to, so a home internet provider and a VPN's data center look completely different. Most VPNs and proxies run out of a handful of hosting providers, and blocking those ASNs cuts off a lot of attack traffic and ban evasion in one rule. Whole regions the server doesn't serve can be blocked the same way.
# soft: invisible check, most real visitors never notice
(http.request.uri.path eq "/")
-> Managed Challenge
# hard: the visitor has to interact before continuing
(http.request.uri.path in {"/register" "/login" "/vote"})
-> Interactive ChallengeFor everything else there are challenges. A soft challenge runs a quick check in the browser that real visitors pass without noticing, while scripts that can't run a browser fail it. A hard challenge makes the visitor click through before continuing. I keep the hard one for pages worth attacking, like registration, login and voting, so regular visitors aren't slowed down everywhere.