Meebo

Java Back-end Server - created in 2019

Source Code

Scripts

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 objects

Most 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, run many times
// 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

resources/config/server.ini
[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.

resources/quests/wedding.js
[
    {
        "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

Login and channel servers
// 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 unloaded

The 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

Handshake packet
// 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 encrypted

The 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

Keep alive
// 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.pingSentAt

A 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.

Channel server login
// 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

Writing and reading a packet
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.

Little-endian encode4
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 last

Everything is little-endian since the client is x86. Java is big-endian by default, so my writer splits each value into bytes by hand.

Element bitmask
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    = 6

Bitmasks 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.

Checking a weakness
000110    weaknesses
& 000010    1 << FIRE
  ------
  000010    non-zero -> weak to fire

if weaknesses & (1 << FIRE):
    damage *= 1.5

Checking 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.

Holy (no weakness)
000110    weaknesses
& 100000    1 << HOLY
  ------
  000000    zero -> not weak to holy

if weaknesses & (1 << HOLY):    // false
    damage *= 1.5               // skipped, normal damage

A 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.

Adding a flag (OR)
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.

Removing a flag (inverted AND)
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.

Toggling a flag (XOR)
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.

Buff bitmask
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))) != 0

Buffs 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

Switch statement (Mushy)
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.

Handler table
// 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 it

I 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.

Attacks
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 AbstractPlayerAttackEvent

Handlers 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.

Movement
// 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 vs per-packet handlers
// 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 only

I 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.

1. Server runs the proxies (Java)
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
2. Script decides (carnival_game.js)
// 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.cp

Scripts 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

Max damage value as a double
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.

Simple patches
// "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 0x90

Cheats 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.

nmconew.dll proxy
// 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 patch

The 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.

Discord Rich Presence
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

Cash Shop storage panel
// 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.

Placing a code cave
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 offset
Jump calculation
patch 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 NOP

The 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.

Hooking CreateWindowExA
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

damage capacity
// 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 directly

Cheat 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.

From cheat table to DLL
// 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 0x007046BB

Quality 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.

Talking to an NPC from anywhere
// 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
Server-side check
// 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 conversation

Some 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.

Remote hack
// 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
Last known IP check
// login server: remember who started the transfer
account.lastKnownIP = client.ip

// channel server: PlayerLoginEvent
if client.ip != account(playerID).lastKnownIP:
    disconnect()
    return

Remote 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.

ImGui overlay hooks
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 result

From 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

Running out of connections
// 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 back

A 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

One save, many round trips
// 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 seconds

Hosting 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

Cascading deletes
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 it

Every 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

Splitting player data
// 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

Server status (PHP)
// 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 offline

Most 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.

Live vote rewards
// 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 login

When 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

Checking reCAPTCHA (PHP)
// 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 account

Without 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

robots.txt
# 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.

WAF custom rules
# 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})
    -> Block

Cloudflare 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.

Challenges
# 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 Challenge

For 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.