Rewriting the classic Helbreath MMORPG (C++/DirectDraw/WinSock) as a web game. Original source in ep_client_final/sources_ep_client/ (client) and ep_client_final/sources_ep_gserver/ (server). Game assets in assets/ folder (446 sprite .pak/.apk files, 83 .amd maps, 231 sound WAVs, 11 music WAVs, 7 font files).
Stack: Go server, Phaser 3 + TypeScript client, PostgreSQL, WebSocket + Protobuf.
MVP: Registration, login, character creation, movement with collision, other players visible, chat, static NPCs. Full specs for all features documented below.
hbonline/
├── server/
│ ├── cmd/
│ │ ├── gameserver/main.go
│ │ └── assetpipeline/main.go # PAK/AMD converter
│ ├── internal/
│ │ ├── game/engine.go # Game loop (10 ticks/sec)
│ │ ├── game/world.go # World state, entity manager
│ │ ├── network/websocket.go # WS connection manager
│ │ ├── network/codec.go # Protobuf encode/decode
│ │ ├── player/player.go # Player entity + state
│ │ ├── player/auth.go # Registration + login
│ │ ├── player/movement.go # Movement validation
│ │ ├── player/appearance.go # Sprite composition data
│ │ ├── npc/npc.go # NPC entity
│ │ ├── npc/behavior.go # AI state machine (Phase 3+)
│ │ ├── npc/spawner.go # Mob generators (Phase 3+)
│ │ ├── mapdata/loader.go # AMD binary parser
│ │ ├── mapdata/map.go # Map struct, tile access
│ │ ├── mapdata/collision.go # Walkability checks
│ │ ├── mapdata/teleport.go # Teleport system
│ │ ├── mapdata/sector.go # Sector-based spatial indexing
│ │ ├── combat/ # Phase 3+
│ │ ├── items/ # Phase 3+
│ │ ├── magic/ # Phase 4+
│ │ ├── skills/ # Phase 4+
│ │ ├── guild/ # Phase 5+
│ │ ├── quest/ # Phase 4+
│ │ └── db/
│ │ ├── postgres.go # Connection pool
│ │ ├── accounts.go # Account CRUD
│ │ └── characters.go # Character CRUD
│ ├── pkg/proto/ # Generated Protobuf Go code
│ └── go.mod
├── client/
│ ├── src/
│ │ ├── scenes/
│ │ │ ├── BootScene.ts # Asset preloading
│ │ │ ├── LoginScene.ts # Login/register forms
│ │ │ ├── CharSelectScene.ts # Character selection
│ │ │ ├── CharCreateScene.ts # Character creation
│ │ │ └── GameScene.ts # Main game
│ │ ├── network/
│ │ │ ├── WebSocketClient.ts # WS connection + reconnect
│ │ │ └── MessageHandler.ts # Route incoming messages
│ │ ├── sprites/
│ │ │ ├── CharacterComposer.ts # Layer equipment sprites
│ │ │ ├── SpriteRegistry.ts # Sprite ID -> atlas mapping
│ │ │ └── AnimationManager.ts # Frame timing per action
│ │ ├── map/
│ │ │ ├── TileRenderer.ts # Tile background/objects
│ │ │ ├── CollisionGrid.ts # Client-side walkability
│ │ │ └── MapTransition.ts # Teleport handling
│ │ ├── entities/
│ │ │ ├── Player.ts # Local + remote players
│ │ │ ├── Npc.ts # NPC entities
│ │ │ └── EntityManager.ts # Track all entities
│ │ ├── ui/
│ │ │ ├── ChatUI.ts # Chat input/display
│ │ │ ├── HUD.ts # HP/MP/SP bars
│ │ │ └── Minimap.ts # Minimap overlay
│ │ ├── audio/
│ │ │ ├── SoundManager.ts # WAV playback with distance
│ │ │ └── MusicManager.ts # Background music
│ │ └── main.ts
│ ├── public/assets/ # Converted assets
│ │ ├── spritesheets/ # PNG atlas + JSON manifest
│ │ ├── maps/ # Converted AMD -> JSON
│ │ ├── sounds/ # WAV files (served as-is)
│ │ └── music/ # WAV/MP3 files
│ ├── package.json
│ └── tsconfig.json
├── proto/ # Protobuf definitions
│ ├── auth.proto
│ ├── game.proto
│ ├── chat.proto
│ └── common.proto
├── tools/
│ ├── pakextract/ # PAK -> PNG sprite sheets
│ └── amdconvert/ # AMD -> JSON map data
├── migrations/ # SQL migrations
├── data/ # Game data (JSON configs)
│ ├── items.json
│ ├── spells.json
│ ├── skills.json
│ ├── npcs.json
│ ├── crafting.json
│ └── quests.json
└── assets/ # Original game assets (source)
├── SPRITES/ (446 .pak/.apk files)
├── MAPDATA/ (83 .amd files)
├── SOUNDS/ (231 .WAV files)
├── MUSIC/ (11 .wav files)
└── FONTS/ (7 .FNT files)
From Sprite.cpp lines 22-46:
PAK File Layout:
Offset 0: File header (24 bytes, reserved/magic)
Offset 24: Sprite index table (8 bytes per sprite entry)
- Bytes 0-3: uint32 absolute offset to sprite data (iASDstart)
- Bytes 4-7: reserved
Per Sprite (at iASDstart):
Offset +0: 100 bytes reserved metadata
Offset +100: int32 m_iTotalFrame (total animation frames)
Offset +104: 4 bytes reserved
Offset +108: stBrush[m_iTotalFrame] (12 bytes each):
int16 sx - source X offset
int16 sy - source Y offset
int16 szx - frame width (pixels)
int16 szy - frame height (pixels)
int16 pvx - pivot X (anchor point)
int16 pvy - pivot Y (anchor point)
After brushes: BMP data (BITMAPFILEHEADER + BITMAPINFOHEADER + palette + pixels)
Supports: 1-bit, 4-bit, 8-bit indexed, 24-bit RGB
First pixel = color key (transparent)
Input: assets/SPRITES/*.pak and *.apk (446 files)
Output per PAK:
- Directory of PNG frames:
public/assets/spritesheets/{pakname}/frame_{N}.png - Phaser atlas JSON:
public/assets/spritesheets/{pakname}.json(TexturePacker format) - Manifest JSON:
public/assets/spritesheets/{pakname}_manifest.jsonwith brush offsets
Implementation steps:
- Open PAK file, read 24-byte header
- Read sprite index entries at offset 24 (iterate until EOF or invalid pointer)
- For each sprite entry:
a. Seek to
iASDstart + 100, readm_iTotalFrameb. Readm_iTotalFramebrush entries (12 bytes each) c. Calculate bitmap offset:iASDstart + 108 + (12 * m_iTotalFrame)d. Parse BMP: read BITMAPFILEHEADER (14 bytes), BITMAPINFOHEADER (40+ bytes) e. If indexed color: read palette f. Read pixel data (bottom-up row order, padded to 4-byte boundary) g. Apply color key transparency (first pixel color -> alpha=0) h. For each frame: extract sub-image using brush{sx, sy, szx, szy}i. Save as PNG with alpha channel - Combine frames into sprite sheet atlas
- Output JSON manifest with frame rects and pivot points (pvx, pvy)
Sprite file categorization (from SpriteID.h and file names):
| Category | Files | Sprite ID Range | Description |
|---|---|---|---|
| Male body/underwear | Ym.pak | 1400+ | Base male character |
| Female body/underwear | Yw.pak | 11400+ | Base female character |
| Male hair | Mhr.pak | 1600+ | 13 hair styles |
| Female hair | Whr.pak | 11600+ | 13 hair styles |
| Male body armor | MHHauberk*.pak, MPMail.pak, MCMail.pak, etc. | 1800+ | 19 armor types |
| Female body armor | WHHauberk*.pak, WPMail.pak, WCMail.pak, etc. | 11800+ | 19 armor types |
| Male leggings | MHLeggings*.pak, MLeggings.pak, MCHoses.pak | 2300+ | 13 types |
| Female leggings | WHLeggings*.pak, WLeggings.pak, WCHoses.pak | 12300+ | 13 types |
| Male boots | MLBoots.pak, MShoes.pak | 2500+ | 6 types |
| Female boots | WLBoots.pak, WShoes.pak | 12500+ | 6 types |
| Male capes | Mmantle01-06.pak | 2600+ | 10 types |
| Female capes | Wmantle01-06.pak | 12600+ | 10 types |
| Male helmets | MHelm1-4.pak, NMHelm1-4.pak, MHCap*.pak | 2750+ | 16 types |
| Female helmets | WHelm1,4.pak, NWHelm1-4.pak, WHCap*.pak | 12750+ | 16 types |
| Male weapons | Msw.pak, Msw2-3.pak, MAxe1-6.pak, MStaff1-3.pak, etc. | 3000+ | 54 types |
| Female weapons | Wsw.pak, Wsw2-3.pak, WAxe1-6.pak, WStaff1-3.pak, etc. | 13000+ | 54 types |
| Male shields | Msh.pak | 6500+ | Multiple types |
| Female shields | Wsh.pak | 16500+ | Multiple types |
| Monsters | SKE.PAK, SLM.PAK, Orc.pak, Demon.pak, etc. | 17000+ | 40+ monster types |
| Map tiles | maptiles1-6.pak, Tile*.pak, whousetiles.pak | Various | Background tiles |
| Objects | Objects1-7.pak, Structures1.pak | Various | Map objects (walls, trees) |
| Effects | EFFECT*.PAK, Effect*.pak, frost.pak, etc. | Various | Spell/combat effects |
| Items | item-equipM/W.pak, item-ground.pak, item-pack.pak | Various | Item sprites |
| UI | GameDialog.pak, New-Dialog*.pak, interface*.pak | Various | UI elements |
| Login | LoginDialog*.apk, selectchar*.apk, newchar*.apk | Various | Login/char screens |
Input: assets/MAPDATA/*.amd (83 map files)
Output per map: public/assets/maps/{mapname}.json
AMD binary format (from MapData.cpp lines 1037-1080):
Byte 0-255: ASCII header (null-padded):
"MAPSIZEX={width}" and "MAPSIZEY={height}"
Byte 256+: Tile data, row-major order (y outer, x inner)
Per tile: 10 bytes:
int16 tileSprite (background sprite ID)
int16 tileSpriteFrame (background frame)
int16 objectSprite (object sprite ID: walls, trees, buildings)
int16 objectSpriteFrame (object frame)
byte flags:
bit 7 (0x80): NOT walkable (inverted: set = blocked)
bit 6 (0x40): teleport location
bit 5 (0x20): farm tile
byte padding
Output JSON format:
{
"name": "aresden",
"width": 256,
"height": 256,
"tiles": [
{
"x": 0, "y": 0,
"tileSprite": 5, "tileSpriteFrame": 0,
"objectSprite": 0, "objectSpriteFrame": 0,
"walkable": true, "teleport": false, "farm": false, "water": false
}
],
"collisionGrid": [[1,1,0,0,...]],
"teleportTiles": [{"x": 10, "y": 20}]
}Water detection: tileSprite == 19 (from Map.cpp line 618).
Map list (83 maps):
- Cities: aresden, elvine, city
- Dungeons: aresdend1, elvined1, dglv2-4, dm, cath_1-2, towerofh, Toh1-3
- Hunt zones: huntzone1-4, fightzone1-9
- Shops: gshop_1-2, bsmith_1-2, whouse, whouse2
- Special: middleland, 2ndmiddle, bisle, icebound, procella, Extreme
- Government: cityhall_1-2, Cmdhall_1-2, wrhus_1-2, wzdtwr_1-2
- University: areuni, elvuni
- Jails: arejail, elvjail
- Barracks: ABarracks, EBarracks, MBarracks
- Events: EventMap, EventDk, NewEvent, ArGEvent, avaevent, evento13-14, masacre
- Special areas: Abaddon, WorldMap, BTField, FightMap, Counter, MapaVip, VipMap1
- Starting: default, resurr1-2, market
1. Client opens WebSocket to ws://server:port/ws
2. Server assigns temporary connection ID
3. Client sends LoginRequest (username + password)
4. Server validates -> sends LoginResponse with character list
5. Client sends EnterGameRequest (character_id)
6. Server loads character, places on map -> sends EnterGameResponse with:
- PlayerContents (full character data)
- MapData (nearby tiles, players, NPCs)
7. Game loop: bidirectional MotionRequest/MotionEvent + ChatRequest/ChatMessage
8. On disconnect: server saves character state, removes from world
Every WebSocket message is a binary frame containing:
byte 0: message type ID (uint8)
bytes 1-N: protobuf-encoded payload
Message type IDs (client -> server):
0x01 = LoginRequest
0x02 = CreateCharacterRequest
0x03 = EnterGameRequest
0x04 = MotionRequest
0x05 = ChatRequest
0x06 = DeleteCharacterRequest
0x10 = CommandRequest (generic action: equip, use item, etc.)
Message type IDs (server -> client):
0x81 = LoginResponse
0x82 = EnterGameResponse
0x83 = MotionEvent
0x84 = ChatMessage
0x85 = PlayerAppear
0x86 = PlayerDisappear
0x87 = NpcAppear
0x88 = NpcDisappear
0x89 = NpcMotion
0x8A = Notification
0x8B = PlayerContents
0x8C = MapChangeResponse
Client sends MotionRequest every movement tick:
message MotionRequest {
int32 direction = 1; // 1=N, 2=NE, 3=E, 4=SE, 5=S, 6=SW, 7=W, 8=NW
int32 action = 2; // 0=stop, 1=walk, 2=run, 3=attack, 4=magic, 5=getitem
Vec2 position = 3; // client-reported tile position
int32 target_id = 4; // target object ID (for attack/magic)
int32 spell_id = 5; // spell ID (for magic action)
}Server validates:
- Check movement speed (anti-hack): walk=~490ms/tile, run=~350ms/tile
- Check destination tile walkability via collision grid
- Check distance moved (max 1 tile per tick for walk, 1 for run)
- If valid: update player position, broadcast MotionEvent to nearby players
- If invalid: send correction MotionEvent back to client with authoritative position
Server broadcasts MotionEvent to all players within 20-tile radius:
message MotionEvent {
int32 object_id = 1;
int32 owner_type = 2; // 1=player, 2=npc
int32 action = 3; // same as MotionRequest.action
int32 direction = 4;
Vec2 position = 5;
Vec2 destination = 6; // for interpolation
int32 speed = 7; // ms per tile
Appearance appearance = 8;
string name = 9;
int32 status = 10; // alive, dead, invisible, etc.
}Direction deltas (from Map.cpp):
Dir 1 (N): dx=0, dy=-1
Dir 2 (NE): dx=1, dy=-1
Dir 3 (E): dx=1, dy=0
Dir 4 (SE): dx=1, dy=1
Dir 5 (S): dx=0, dy=1
Dir 6 (SW): dx=-1, dy=1
Dir 7 (W): dx=-1, dy=0
Dir 8 (NW): dx=-1, dy=-1
type Engine struct {
maps map[string]*GameMap // loaded maps
players map[int32]*Player // connected players by object ID
npcs map[int32]*NPC // active NPCs by object ID
msgQueue chan IncomingMessage // from WebSocket handlers
ticker *time.Ticker // 100ms game loop
nextObjID int32 // incrementing object ID counter
db *pgxpool.Pool
}
func (e *Engine) Run() {
for {
select {
case <-e.ticker.C:
e.processTick()
case msg := <-e.msgQueue:
e.handleMessage(msg)
}
}
}
func (e *Engine) processTick() {
now := time.Now()
// Phase 2: nothing beyond message processing
// Phase 3+: NPC AI, regen timers, weather, day/night
}type GameMap struct {
Name string
Width int
Height int
Tiles [][]Tile // [y][x]
Sectors [][]Sector // spatial index (20x20 tile sectors)
Teleports []TeleportLoc
Spawns []SpawnPoint // player spawn points
PKMode int // 0=all, 1=no-same-faction, 2=cross-faction, 3=none
XPRate int // percentage (100 = normal)
}
type Tile struct {
TileSprite int16
TileSpriteFrame int16
ObjectSprite int16
ObjectSpriteFrame int16
Walkable bool
Teleport bool
Farm bool
Water bool
// Runtime state:
Owner int32 // player/NPC object ID on this tile
Items []GroundItem // items on ground
DynamicObject int16 // fire, chest, etc.
}
type TeleportLoc struct {
SrcX, SrcY int
DestMap string
DestX, DestY int
Direction int
}Parse AMD: read 256-byte header, extract MAPSIZEX/Y, then read width*height*10 bytes of tile data.
Divide each map into 20x20 tile sectors. Each sector tracks which player/NPC object IDs are present.
type Sector struct {
Players map[int32]bool
NPCs map[int32]bool
}
// Get all entities near a position (check 3x3 sector grid around player)
func (m *GameMap) GetNearbyEntities(x, y int) (players []int32, npcs []int32)When a player moves between sectors, compute diff of visible entities and send PlayerAppear/PlayerDisappear events.
type Player struct {
ObjectID int32
AccountID int
CharacterID int
Name string
// Position
MapName string
X, Y int
Direction int // 1-8
Action int // current action
// Appearance (sent to other players)
Gender int
SkinColor int
HairStyle int // 0-12
HairColor int
UnderwearColor int
BodyArmor int // equipment sprite index
ArmArmor int
Leggings int
Helm int
Weapon int
Shield int
Cape int
Boots int
ApprColor int // dye color
// Stats (Phase 3+)
Level int
Exp int64
HP, MaxHP int
MP, MaxMP int
SP, MaxSP int
STR, VIT, DEX, INT, MAG, CHR int
LUPool int // unspent stat points
Side int // 0=neutral, 1=aresden, 2=elvine
// Anti-hack
LastMoveTime time.Time
MoveCount int // moves per second limiter
// Connection
Conn *websocket.Conn
SendChan chan []byte
}Registration:
- Client sends username (3-20 chars, alphanumeric) + password (6-30 chars)
- Server validates uniqueness
- Hash password with bcrypt (cost 12)
- Insert into
accountstable - Return success + empty character list
Login:
- Client sends username + password
- Server looks up account, verify bcrypt hash
- Load character list (max 4 per account) from
characterstable - Return character summaries (name, level, gender, side, map)
Character creation:
- Client sends: name (3-10 chars), gender (0/1), skin_color (0-2), hair_style (0-12), hair_color (0-7), underwear_color (0-7), stat allocation (STR/VIT/DEX/INT/MAG/CHR)
- Server validates: name unique, total stat points = 70 (10 base * 6 stats + 10 bonus), each stat >= 10
- Insert into
characterstable with default map="default", position from map spawn points - Return updated character list
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO, // WebGL with Canvas fallback
width: 640, // original viewport width
height: 480, // original viewport height
pixelArt: true, // disable anti-aliasing for sprites
scale: {
mode: Phaser.Scale.FIT, // scale to fit browser window
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [BootScene, LoginScene, CharSelectScene, CharCreateScene, GameScene],
};The game uses a top-down tile system with 32x32 pixel tiles (NOT true isometric despite the visual style - the original uses orthogonal grid with isometric-looking sprites).
class TileRenderer {
private tilemap: Phaser.GameObjects.Container;
private viewportWidth = 21; // tiles visible horizontally
private viewportHeight = 16; // tiles visible vertically
renderViewport(playerX: number, playerY: number, mapData: MapData) {
// Clear previous tiles
// Calculate visible tile range centered on player
const startX = playerX - Math.floor(this.viewportWidth / 2);
const startY = playerY - Math.floor(this.viewportHeight / 2);
for (let dy = 0; dy < this.viewportHeight; dy++) {
for (let dx = 0; dx < this.viewportWidth; dx++) {
const tileX = startX + dx;
const tileY = startY + dy;
const tile = mapData.getTile(tileX, tileY);
// Draw background tile sprite
if (tile.tileSprite > 0) {
this.drawSprite(tile.tileSprite, tile.tileSpriteFrame,
dx * 32, dy * 32);
}
// Draw object sprite (walls, trees, buildings) on top
if (tile.objectSprite > 0) {
this.drawSprite(tile.objectSprite, tile.objectSpriteFrame,
dx * 32, dy * 32);
}
}
}
}
}Characters are rendered by layering multiple sprites. The drawing order varies by direction.
class CharacterComposer {
// Drawing order indexed by direction [dir] => 0=before body, 1=after body
static DRAW_ORDER = [0, 1, 0, 0, 0, 0, 0, 1, 1]; // dir 0-8
static MANTLE_ORDER = [0, 1, 1, 1, 0, 0, 0, 2, 2]; // cape special order
// Sprite ID bases (from SpriteID.h)
static SPRITE_BASES = {
UNDIES_M: 1400, UNDIES_W: 11400,
HAIR_M: 1600, HAIR_W: 11600, // +style*120 (13 styles, 120 frames each)
ARMOR_M: 1800, ARMOR_W: 11800, // +type*120 (19 types)
BERK_M: 2100, BERK_W: 12100, // +type*120 (13 types)
LEGG_M: 2300, LEGG_W: 12300, // +type*120
BOOT_M: 2500, BOOT_W: 12500, // +type*120
MANTLE_M: 2600, MANTLE_W: 12600, // +type*120
HEAD_M: 2750, HEAD_W: 12750, // +type*120
WEAPON_M: 3000, WEAPON_W: 13000, // +type*120
SHIELD_M: 6500, SHIELD_W: 16500,
MOB: 17000, // +mobType*120
};
// Each sprite has 120 frames: 8 directions * 15 action frames
// Frame index = (direction-1)*15 + actionFrame
compose(appearance: Appearance, direction: number, action: number, frame: number): Phaser.GameObjects.Container {
const layers: SpriteLayer[] = [];
const isMale = appearance.gender === 0;
const base = isMale ? 'M' : 'W';
// 1. Body/underwear (always drawn first)
layers.push({ spriteId: isMale ? 1400 : 11400, zIndex: 0 });
// 2. Body armor (if equipped)
if (appearance.bodyArmor > 0) {
const armorBase = isMale ? 1800 : 11800;
layers.push({ spriteId: armorBase + appearance.bodyArmor * 120, zIndex: 1 });
}
// 3. Leggings
if (appearance.leggings > 0) {
layers.push({ spriteId: (isMale ? 2300 : 12300) + appearance.leggings * 120, zIndex: 2 });
}
// 4. Boots
if (appearance.boots > 0) {
layers.push({ spriteId: (isMale ? 2500 : 12500) + appearance.boots * 120, zIndex: 3 });
}
// 5. Hair (always drawn, index = hairStyle * 120)
layers.push({ spriteId: (isMale ? 1600 : 11600) + appearance.hairStyle * 120, zIndex: 4 });
// 6. Helmet (replaces/overlays hair)
if (appearance.helm > 0) {
layers.push({ spriteId: (isMale ? 2750 : 12750) + appearance.helm * 120, zIndex: 5 });
}
// 7. Cape/mantle (draw order depends on direction)
if (appearance.cape > 0) {
const mantleOrder = CharacterComposer.MANTLE_ORDER[direction];
layers.push({ spriteId: (isMale ? 2600 : 12600) + appearance.cape * 120,
zIndex: mantleOrder === 0 ? -1 : mantleOrder === 2 ? 10 : 6 });
}
// 8. Weapon
if (appearance.weapon > 0) {
const weaponOrder = CharacterComposer.DRAW_ORDER[direction];
layers.push({ spriteId: (isMale ? 3000 : 13000) + appearance.weapon * 120,
zIndex: weaponOrder === 0 ? -2 : 7 });
}
// 9. Shield
if (appearance.shield > 0) {
layers.push({ spriteId: (isMale ? 6500 : 16500) + appearance.shield * 120, zIndex: 8 });
}
// Sort by zIndex and render
layers.sort((a, b) => a.zIndex - b.zIndex);
// Calculate frame: (direction-1) * framesPerAction + currentFrame
const frameIndex = (direction - 1) * getFrameCount(action) + frame;
return this.renderLayers(layers, frameIndex);
}
}From the original m_stFrame table (MapData.cpp):
const ANIMATION_FRAMES: Record<string, {frames: number, duration: number}> = {
// Player animations (per direction)
'player_stop': { frames: 1, duration: 400 }, // idle: 1 keyframe, loop
'player_walk': { frames: 7, duration: 70 }, // 7 frames @ 70ms = 490ms total
'player_run': { frames: 7, duration: 50 }, // 7 frames @ 50ms = 350ms total
'player_attack': { frames: 7, duration: 78 }, // 7 frames @ 78ms = 546ms
'player_magic': { frames: 7, duration: 78 },
'player_getitem': { frames: 3, duration: 100 },
'player_damage': { frames: 3, duration: 80 },
'player_dying': { frames: 7, duration: 120 },
// Monster animations (examples)
'slime_stop': { frames: 3, duration: 240 },
'slime_walk': { frames: 7, duration: 120 },
'slime_attack': { frames: 7, duration: 100 },
'skeleton_stop': { frames: 3, duration: 150 },
'skeleton_walk': { frames: 7, duration: 90 },
'skeleton_attack':{ frames: 7, duration: 90 },
};Sound categories (from SoundID.h):
| Prefix | Files | Purpose |
|---|---|---|
| C*.WAV | C1-C24 (24 files) | Character sounds |
| E*.WAV | E1-E53 (53 files) | Effect/environmental sounds |
| M*.WAV | M1-M156 (156 files) | Monster/misc sounds |
Key sound mappings:
const SOUND_MAP = {
// Character sounds (C prefix)
SHORT_SWORD_ATTACK: 'C1',
LONG_SWORD_ATTACK: 'C2',
BOW_AIM: 'C3',
BOW_SHOOT: 'C4',
AXE_ATTACK: 'C5',
MALE_DAMAGE: 'C6',
FEMALE_DAMAGE: 'C7',
WALK_LAND: 'C8',
WALK_GLASS: 'C9',
RUN_LAND: 'C10',
RUN_GLASS: 'C11',
MALE_DYING: 'C12',
FEMALE_DYING: 'C13',
BAREHAND_HIT: 'C14',
SWORD_HIT: 'C15',
MACE_HIT: 'C16',
ARROW_HIT: 'C17',
// Effect sounds (E prefix)
PROTECTION_SPELL: 'E1',
ENERGY_BOLT: 'E2',
MAGIC_MISSILE: 'E3',
FIREBALL: 'E4',
GOLD_DROP: 'E12',
UI_CLICK: 'E14',
HEAL_SPELL: 'E45',
ICE_SPELL: 'E46',
BLIZZARD: 'E47',
WEATHER_LOOP: 'E38',
};Distance-based volume (from Game.cpp):
function calculateVolume(playerX: number, playerY: number, soundX: number, soundY: number): number {
const dist = Math.max(Math.abs(playerX - soundX), Math.abs(playerY - soundY));
if (dist > 10) return 0; // too far to hear
return Math.max(0, 1.0 - (dist * 0.1)); // linear falloff
}Background music files:
const MUSIC_MAP: Record<string, string> = {
'aresden': 'aresden.wav',
'elvine': 'elvine.wav',
'middleland': 'middleland.wav',
'default': 'MainTm.wav',
'dungeon': 'dungeon.wav', // catacombs, dglv*, towerofh
'city': 'druncncity.wav',
'abaddon': 'abaddon.wav',
// Carol*.wav for holiday events
};CREATE TABLE accounts (
id SERIAL PRIMARY KEY,
username VARCHAR(20) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
last_login TIMESTAMPTZ,
is_banned BOOLEAN DEFAULT FALSE
);
CREATE TABLE characters (
id SERIAL PRIMARY KEY,
account_id INT REFERENCES accounts(id) ON DELETE CASCADE,
name VARCHAR(10) UNIQUE NOT NULL,
gender SMALLINT NOT NULL DEFAULT 0,
skin_color SMALLINT DEFAULT 0,
hair_style SMALLINT DEFAULT 0, -- 0-12
hair_color SMALLINT DEFAULT 0, -- 0-7
underwear_color SMALLINT DEFAULT 0, -- 0-7
side SMALLINT DEFAULT 0, -- 0=neutral, 1=aresden, 2=elvine
level INT DEFAULT 1,
experience BIGINT DEFAULT 0,
str INT DEFAULT 10,
vit INT DEFAULT 10,
dex INT DEFAULT 10,
int_stat INT DEFAULT 10,
mag INT DEFAULT 10,
charisma INT DEFAULT 10,
lu_pool INT DEFAULT 0,
hp INT DEFAULT 30,
mp INT DEFAULT 10,
sp INT DEFAULT 30,
map_name VARCHAR(30) DEFAULT 'default',
pos_x INT DEFAULT 0,
pos_y INT DEFAULT 0,
direction SMALLINT DEFAULT 5,
admin_level SMALLINT DEFAULT 0,
pk_count INT DEFAULT 0,
ek_count INT DEFAULT 0,
reward_gold INT DEFAULT 0,
hunger INT DEFAULT 100,
gold BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_played TIMESTAMPTZ
);
-- Phase 3+
CREATE TABLE character_items (
id SERIAL PRIMARY KEY,
character_id INT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
item_name VARCHAR(30) NOT NULL,
count INT DEFAULT 1,
cur_lifespan INT DEFAULT 0,
max_lifespan INT DEFAULT 0,
attribute BIGINT DEFAULT 0,
location SMALLINT DEFAULT 0, -- 0=inventory, 1=equipped, 2=bank, 3=ground
slot_index SMALLINT DEFAULT -1,
equip_pos SMALLINT DEFAULT 0,
effect_type SMALLINT DEFAULT 0,
effect_value1 INT DEFAULT 0,
effect_value2 INT DEFAULT 0
);
CREATE TABLE character_skills (
character_id INT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
skill_id SMALLINT NOT NULL,
mastery INT DEFAULT 0,
ssn INT DEFAULT 0,
PRIMARY KEY (character_id, skill_id)
);
CREATE TABLE character_magic (
character_id INT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
magic_id SMALLINT NOT NULL,
mastery INT DEFAULT 0,
PRIMARY KEY (character_id, magic_id)
);
CREATE TABLE guilds (
id SERIAL PRIMARY KEY,
name VARCHAR(20) UNIQUE NOT NULL,
side SMALLINT NOT NULL,
master_char_id INT REFERENCES characters(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE guild_members (
guild_id INT NOT NULL REFERENCES guilds(id) ON DELETE CASCADE,
character_id INT NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
rank SMALLINT DEFAULT 1,
PRIMARY KEY (guild_id, character_id)
);- Build
pakextracttool in Go - parse PAK/APK binary files, output PNG + atlas JSON - Build
amdconverttool in Go - parse AMD binary files, output JSON with collision grids - Set up Go project with WebSocket server (gorilla/websocket)
- Set up Phaser 3 + TypeScript project with Vite bundler
- Define Protobuf schemas, generate Go + TypeScript code
- Create PostgreSQL migrations, set up connection pool
- Account registration + login (bcrypt, JWT session)
- Character creation with appearance selection
- Character select screen (show up to 4 characters)
- Load AMD map data on server, send tile data to client
- Render tile map in Phaser (background + objects)
- Player movement: client-side prediction + server validation + collision
- Camera follows player with smooth scrolling
- Other players appear/disappear/move in real-time
- Character sprite composition (layered equipment rendering)
- Chat system (normal + shout, bubble above character head)
- Static NPCs visible on map (shopkeepers, guards, quest givers)
- Map transitions via teleport tiles
- Save/load character position on login/logout
- Walking/running sound effects
- Background music per map
- Melee combat with hit/miss roll (1d100 vs HitRatio-DefenseRatio)
- Damage calculation (dice + STR scaling + armor reduction, capped at 80%)
- Critical hits (Level% bonus damage)
- Combo system (0-4 consecutive hits)
- NPC AI state machine (stop, move, attack, flee, dead)
- NPC pathfinding (A* on collision grid)
- NPC aggro system (detection range, target selection)
- HP/MP/SP bars and regeneration (HP:15s, MP:20s, SP:10s)
- Death and respawn (return to home city spawn)
- Experience gain from NPC kills (with level scaling)
- Level up: +3 stat points, stat allocation UI
- Item drop system (NPC loot tables)
- Ground items (render on map, pick up on walk-over or click)
- Inventory UI (50 slots, drag and drop)
- Equipment system (13 slots, changes character appearance)
- Item durability (cur/max lifespan, degrades on use)
- Shops: NPC shop UI, buy/sell with gold
- Potions (HP/MP/SP recovery, instant use)
- Combat sound effects (weapon hit, damage, death)
- Combat visual effects (hit flash, death animation)
- Skill system: 24 skills with mastery levels (0-100)
- Skill learning: buy manual from shop, use to learn
- Skill advancement: mastery increases through use
- Skill cap: total 700 mastery across all skills
- Mining: click mineral spots with PickAxe equipped, roll skill check, get ore
- Fishing: click fish spots with FishingRod, roll skill check, get fish
- Farming: use Hoe on farm tiles, plant seeds, wait for growth, harvest
- Crafting/Alchemy: combine materials at anvil/bowl, skill check, produce item
- 102 crafting recipes (see data/crafting.json for full list)
- Magic system: 98 spells with INT requirements and mana costs
- Spell casting: select spell, target location/entity, animation + effect
- Spell categories: damage (spot/area/linear), heal, buff, debuff, summon, teleport
- Area of effect: calculate tiles in radius, apply damage/effect to entities
- Spell visual effects (from effect PAK files)
- Buff/debuff system: timed status effects (protection, poison, berserk, etc.)
- Potion crafting and use
- Ranged combat: bows with arrow ammunition, distance damage falloff
- Taming: attempt to tame wild monsters
- Faction selection at Cityhall NPC (level 10+, irreversible)
- Faction effects: home city, respawn point, hero equipment access
- Guild creation: CHR >= 20, Level >= 20, name selection
- Guild management: invite/kick members, ranks, guild chat
- Party system: invite up to 8 players, shared XP (with proximity bonus)
- Whisper chat (private messages by character name)
- Guild chat channel
- PvP: hit other players based on map PK mode
- PK/EK tracking and criminal status
- Death penalties: XP loss (% of level), random item drop chance
- 7-second protection after PvP death
- Safe zones: no-attack rectangles within maps
- Trade between players: request, offer items, confirm
- Quest system: monster hunt, delivery, assassination, escort
- Quest rewards: XP, gold, items, contribution points
- Crusade war: faction-wide PvP event with roles (soldier/constructor/commander)
- Crusade buildings: guard towers, mana collectors, detectors, magic generators
- Crusade victory: destroy all enemy principal buildings
- Arena/Bleeding Isle: organized PvP with ticket entry
- Day/night cycle: server-wide, affects spawns and weapon bonuses
- Weather system: snow/rain visual effects
- Summoned creatures: summon spells create temporary NPC allies (5-10 min)
- Boss monsters: special NPCs with unique abilities
- Special events: Heldenian tower defense, Apocalypse mode
- Post-max-level progression: Gizon points for extra stat upgrades
- Run pakextract on all 446 PAK/APK files -> verify PNG output with correct transparency
- Run amdconvert on all 83 AMD files -> verify JSON output, spot-check walkability against screenshots
- Start Go server -> verify WebSocket accepts connections
- Load Phaser client -> verify sprite sheet loads and single frame renders correctly
- Register account via web form -> verify bcrypt hash in PostgreSQL
- Create 4 characters with different appearances -> verify all saved correctly
- Enter game -> verify map renders with correct tiles from AMD data
- Walk around Aresden city -> verify collision blocks at buildings/walls/water
- Walk to edge of map -> verify teleport to connected map works
- Open second browser -> verify both players see each other and movement syncs
- Type chat -> verify bubble appears above character head for other player
- Walk near NPC -> verify NPC sprite renders at correct position
- Disconnect -> reconnect -> verify character at same position
- Verify walking sound plays on each step, music plays per map