How to build a web item shop (webshop) for MU Online
A complete guide to building a secure webshop for your MU Online server, integrating credit balances, automatic item delivery to the warehouse, and protection against fraud.
A webshop (web item shop) is one of the features that most sets an amateur MU Online server apart from a professional project. It lets players trade credits — bought through donations or earned in events — for items, wings, kits, and services directly in the browser, without depending on an online G
A webshop (web item shop) is one of the features that most sets an amateur MU Online server apart from a professional project. It lets players trade credits — bought through donations or earned in events — for items, wings, kits, and services directly in the browser, without depending on an online GM. Built well, it runs 24 hours a day, cuts down the team's manual work, and becomes one of the server's main sources of revenue and retention. Built badly, it turns into a security hole that hands out infinite items, corrupts characters, and allows balance fraud.
This tutorial is advanced because a webshop touches the three most sensitive parts of your ecosystem at the same time: the account/character database, the binary item format of your Season, and the web layer exposed to the internet. We'll build a correct architecture from scratch, with secure delivery, atomic transactions, and protection against the classic frauds. If you don't have the server up yet, start with the base guide on how to create a MU Online server and come back here afterward.
Prerequisites
Before writing a single line of shop code, make sure you have the minimum environment and knowledge:
- A working MU Online server (GameServer, ConnectServer, and DataServer up).
- A database accessible from the web application — SQL Server (MuOnline/MuOnline DB) on most Seasons, or MySQL/MariaDB on modern distros.
- A website already published with PHP 8.1+ (or whatever stack you use) and a working database connection.
- An account system with working login on the site and a balance/credits field (WCoin, Credits, Cash, or an equivalent column).
- Knowledge of your Season's item format — index, type, level, options, excellent, ancient, luck, and skill.
- A test environment (a disposable account and character) to validate deliveries before releasing them to players.
> Security warning: never develop the webshop directly against the production database. A single mistake in an item INSERT can corrupt real characters. Always work with a test dump first.
How a webshop actually delivers items
There's a common misconception: many people think the webshop "talks to the server." In practice, the overwhelming majority of MU web shops do not talk directly to the GameServer — they write the item into the warehouse table in the database, and the GameServer reads that table when the player opens the warehouse.
This has a critical consequence: the item only appears reliably when the character is offline. While the player is logged in, the server keeps a copy of the inventory and warehouse in memory. If you write to the database at that moment, the server overwrites everything when it saves on logout, and the purchased item vanishes — generating a ticket, an angry player, and a refund request.
That's why the correct architecture is asynchronous:
- The player buys on the web; the balance is debited and an order is recorded.
- The delivery goes into a queue (table
webshop_delivery_queue). - A process checks whether the character is offline.
- When offline, the item is written to the warehouse inside a transaction and the order is marked as delivered.
| Delivery model | How it works | Pros | Cons |
|---|---|---|---|
| Direct write to warehouse (offline) | Shop inserts the item into the warehouse table | Simple, no source changes | Only works when the player is logged out |
| Queue + worker | Order is queued and processed by a service | Secure, auditable, scalable | Requires a scheduled process running |
| Real-time socket | Web sends a command to the GameServer via custom source | Instant delivery while online | Requires editing and recompiling the source |
| In-game coupon/redeem | Web generates a code, player redeems via NPC/command | Zero direct database writes | Friction for the player |
For 90% of servers, the queue + worker model is the right one. That's what we'll build.
Step 1 — Model the shop tables
Create tables that belong to the webshop, separate from the game tables. Never reuse the MU tables to control the shop. Example in SQL Server (adjust the types for MySQL if that's your case):
-- Shop product catalog
CREATE TABLE WebshopProducts (
ProductId INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(120) NOT NULL,
Category NVARCHAR(40) NOT NULL,
PriceCredits INT NOT NULL,
ItemHex VARCHAR(64) NOT NULL, -- item hex for the Season
DurabilityQty INT NOT NULL DEFAULT 1,
Active BIT NOT NULL DEFAULT 1,
CreatedAt DATETIME NOT NULL DEFAULT GETDATE()
);
-- Orders (auditing and idempotency)
CREATE TABLE WebshopOrders (
OrderId BIGINT IDENTITY(1,1) PRIMARY KEY,
AccountId VARCHAR(20) NOT NULL,
CharName VARCHAR(20) NOT NULL,
ProductId INT NOT NULL,
PriceCredits INT NOT NULL,
IdemToken CHAR(36) NOT NULL UNIQUE, -- prevents duplicate purchase
Status VARCHAR(20) NOT NULL DEFAULT 'PAID', -- PAID/QUEUED/DELIVERED/FAILED
IpAddress VARCHAR(45) NULL,
CreatedAt DATETIME NOT NULL DEFAULT GETDATE()
);
-- Delivery queue
CREATE TABLE WebshopDeliveryQueue (
QueueId BIGINT IDENTITY(1,1) PRIMARY KEY,
OrderId BIGINT NOT NULL,
CharName VARCHAR(20) NOT NULL,
ItemHex VARCHAR(64) NOT NULL,
Attempts INT NOT NULL DEFAULT 0,
Delivered BIT NOT NULL DEFAULT 0,
CreatedAt DATETIME NOT NULL DEFAULT GETDATE()
);
The IdemToken column is the heart of the duplicate protection: each purchase attempt generates a UUID; the UNIQUE index guarantees the same attempt never becomes two orders, even if the player double-clicks or the connection drops and they resend.
Step 2 — Understand your Season's item format
This is the point that breaks webshops the most. Each item in MU is represented by a binary sequence with fields at fixed offsets. The format varies by version — below is an ILLUSTRATIVE example based on the classic 32-byte layout (Season 6 style); confirm the offsets in your distro's documentation before using it in production.
| Field (example) | Approx. bytes | Meaning |
|---|---|---|
| Index/Section | 0-1 | Which item (e.g., 0x0007 = sword X) |
| Level | 1 | Refine level +0 to +15 |
| Options (dur) | 2 | Additional option / durability |
| Excellent flags | 1 | Bitmask of the excellent options |
| Ancient/Set | 1 | Ancient item and set bonus |
| Luck/Skill | bits | Luck and skill flags |
| Serial | 4-8 | Unique item serial |
A builder in PHP that assembles the hex from readable parameters makes registering products far less error-prone:
<?php
// SIMPLIFIED example — the REAL offsets vary by version/Season.
function buildItemHex(array $it): string {
$index = $it['index'] ?? 0; // item code
$level = $it['level'] ?? 0; // 0..15
$skill = !empty($it['skill']) ? 1 : 0;
$luck = !empty($it['luck']) ? 1 : 0;
$option = $it['option'] ?? 0; // 0..3 (option +4/+8/...)
$exc = $it['exc'] ?? 0; // excellent bitmask (0..63)
// Level byte: usually level << 3 combined with the skill bit
$lvlByte = (($level & 0x0F) << 3) | ($skill << 7);
// Option byte: option in the low bits + luck
$optByte = ($option & 0x03) | ($luck ? 0x04 : 0x00);
$bytes = [
$index & 0xFF,
$lvlByte & 0xFF,
$optByte & 0xFF,
$exc & 0xFF,
];
return strtoupper(bin2hex(pack('C*', ...$bytes)));
}
echo buildItemHex(['index'=>7,'level'=>11,'exc'=>0x3F,'luck'=>true,'skill'=>true]);
> Important: the code above is illustrative. Don't copy the offsets blindly — take the correct layout from your Season (the distro's documentation or the source's own ItemManager/ItemConvert). A wrong offset produces an invalid item that freezes the player's warehouse.
Step 3 — The purchase flow with an atomic transaction
The purchase must be atomic: debiting the balance and recording the order have to happen together or not at all. Never debit credit in one command and insert the order in another without a transaction — a failure in between leaves the player with no credit and no item.
<?php
function comprarItem(PDO $pdo, string $accountId, string $charName, int $productId): array {
$idem = bin2hex(random_bytes(16)); // idempotency token
$idem = substr(preg_replace('/(.{8})(.{4})(.{4})(.{4})(.{12})/','$1-$2-$3-$4-$5', $idem),0,36);
$pdo->beginTransaction();
try {
// 1) Lock the product row and validate
$prod = $pdo->prepare("SELECT PriceCredits, ItemHex, Active FROM WebshopProducts WHERE ProductId = ?");
$prod->execute([$productId]);
$p = $prod->fetch(PDO::FETCH_ASSOC);
if (!$p || !$p['Active']) throw new RuntimeException('Product unavailable');
// 2) Debit the balance ONLY if there is enough credit (check in the WHERE clause)
$upd = $pdo->prepare(
"UPDATE MEMB_CREDITS SET Credits = Credits - ?
WHERE AccountId = ? AND Credits >= ?");
$upd->execute([$p['PriceCredits'], $accountId, $p['PriceCredits']]);
if ($upd->rowCount() === 0) throw new RuntimeException('Insufficient balance');
// 3) Record the order (UNIQUE(IdemToken) prevents duplicates)
$ins = $pdo->prepare(
"INSERT INTO WebshopOrders (AccountId, CharName, ProductId, PriceCredits, IdemToken, Status, IpAddress)
VALUES (?,?,?,?,?, 'QUEUED', ?)");
$ins->execute([$accountId,$charName,$productId,$p['PriceCredits'],$idem,$_SERVER['REMOTE_ADDR'] ?? null]);
$orderId = (int)$pdo->lastInsertId();
// 4) Queue the delivery
$q = $pdo->prepare(
"INSERT INTO WebshopDeliveryQueue (OrderId, CharName, ItemHex) VALUES (?,?,?)");
$q->execute([$orderId, $charName, $p['ItemHex']]);
$pdo->commit();
return ['ok'=>true, 'orderId'=>$orderId];
} catch (\Throwable $e) {
$pdo->rollBack();
return ['ok'=>false, 'error'=>$e->getMessage()];
}
}
Notice two fundamental security details:
- The debit is done with
WHERE ... AND Credits >= ?. That way, even with two simultaneous requests (a race condition), the database guarantees that only one succeeds in debiting when the balance is tight — the second one returnsrowCount() === 0. - Every query uses prepared statements. Never concatenate
$accountIdor$productIddirectly into the SQL.
Step 4 — The delivery worker (with an offline check)
The worker is a script run periodically (Task Scheduler on Windows, cron on Linux) that processes the queue. It only delivers if the character is offline.
<?php
// worker_entrega.php — run every 1 minute
require 'db.php';
$pendentes = $pdo->query(
"SELECT TOP 50 QueueId, OrderId, CharName, ItemHex
FROM WebshopDeliveryQueue WHERE Delivered = 0 AND Attempts < 5")->fetchAll(PDO::FETCH_ASSOC);
foreach ($pendentes as $row) {
// 1) Is it online? (the MEMB_STAT/ConnectStat table varies by version)
$st = $pdo->prepare("SELECT ConnectStat FROM MEMB_STAT ms
JOIN Character c ON c.AccountID = ms.memb___id WHERE c.Name = ?");
$st->execute([$row['CharName']]);
$online = (int)($st->fetchColumn() ?: 0);
if ($online === 1) { continue; } // leave it in the queue until logout
$pdo->beginTransaction();
try {
// 2) Write the item to a free warehouse slot
// The warehouse table and offsets vary by version.
$ok = inserirItemNoBau($pdo, $row['CharName'], $row['ItemHex']);
if (!$ok) throw new RuntimeException('Warehouse full or invalid slot');
// 3) Mark as delivered
$pdo->prepare("UPDATE WebshopDeliveryQueue SET Delivered=1 WHERE QueueId=?")
->execute([$row['QueueId']]);
$pdo->prepare("UPDATE WebshopOrders SET Status='DELIVERED' WHERE OrderId=?")
->execute([$row['OrderId']]);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
$pdo->prepare("UPDATE WebshopDeliveryQueue SET Attempts=Attempts+1 WHERE QueueId=?")
->execute([$row['QueueId']]);
}
}
The inserirItemNoBau function finds an empty slot in the character's warehouse and writes the item blob at the correct position. Since the warehouse layout (the warehouse table, the Items column, the size of each slot) varies by version, this part needs to be adapted to your Season. The principle, though, is universal: find free space, write the hex at the slot's offset, and don't overflow the capacity.
Step 5 — Front-end and player experience
On the front-end, display the catalog by category, the current balance, and a purchase button with confirmation. One security point many people forget: disable the button after the click to prevent resubmission, and validate everything again on the server — the front-end is never the source of truth.
// Block double-clicks on the purchase button
const btn = document.querySelector('#comprar');
btn.addEventListener('click', async (e) => {
btn.disabled = true;
btn.textContent = 'Processing...';
try {
const r = await fetch('/loja/comprar', {
method: 'POST',
headers: {'Content-Type':'application/json','X-CSRF-Token': window.csrf},
body: JSON.stringify({ productId: btn.dataset.pid, char: window.charName })
});
const data = await r.json();
alert(data.ok ? 'Item queued! Log out to receive it in the warehouse.' : 'Error: ' + data.error);
} finally {
btn.disabled = false;
btn.textContent = 'Buy';
}
});
Always tell the player clearly: "Log the character out to receive the item in the warehouse". That single sentence cuts most of the "I bought it and it didn't arrive" tickets.
Step 6 — Pricing and balancing
The technical part is half the work; the other half is what you sell. Selling raw power (full excellent items, +15 sets) turns the server into pay-to-win and drives the free base away. Healthy strategies:
- Cosmetics and convenience: name change, class change, stat reset, visual wings, transformations.
- Moderate progression items: starter kits, jewel bundles, temporary XP buffs.
- Services: extra warehouse slot, guild expansion, VIP rental.
Set the price in credits based on how much play time that item represents. An item that takes 10 hours of farming can't cost the equivalent of $1 in credits, or the farming loses its meaning.
Common errors and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Purchased item doesn't appear in the warehouse | Character was online during delivery | Confirm the offline check in the worker; deliver only with ConnectStat=0 |
| Corrupted warehouse / freezes when opening | Item hex with the wrong offset for the Season | Rebuild the hex with the correct table for your version in a test environment |
| Player bought twice with 1 click | Missing idempotency | Implement the UNIQUE IdemToken and disable the button on the front |
| Balance went negative | Debit without a check in the WHERE | Use UPDATE ... WHERE Credits >= price and check rowCount |
| Duplicate item after a network failure | Retry without a token | Always resend the same IdemToken from the original attempt |
| Slow shop / freezes under load | Queries without indexes and without transactions | Index CharName/Status and wrap the purchase in a transaction |
| Price changed by the client | Trusting the value coming from the front | Always read the price from the database by ProductId, never from the request |
Launch checklist
- Shop tables created and kept separate from the game tables
- Item format validated on your Season with a test account
- Purchase runs inside an atomic transaction (debit + order + queue)
- UNIQUE IdemToken active against duplicate purchases
- Credit debit with
WHERE Credits >= price - Delivery worker processes only offline characters
- All queries using prepared statements
- Purchase button disables after the click and validates CSRF
- Prices always read from the database, never from the front-end
- Indexes on CharName, Status, and Delivered for performance
- Clear "log out to receive" notice visible in the shop
- Audit logs (who bought what, when, and from which IP)
- Full-warehouse scenario tested (retry without losing the item or the credit)
With this architecture you have a reliable, auditable webshop that resists the classic MU Online frauds. Start small, with a few convenience products, validate delivery exhaustively in testing, and only then open it to players.
Frequently asked questions
Does the webshop deliver items while the player is online?
Ideally you should deliver only while the character is offline. If the player is logged in, the server keeps the inventory data in memory and writing directly to the database will be overwritten on logout. Queue the delivery and process it once the status is offline.
Do I need to touch the GameServer source code to have a webshop?
Not necessarily. Most webshops deliver items by writing directly to the warehouse table in the database. You only need to touch the source if you want real-time delivery over a socket, which is more advanced.
How do I generate an item's hexadecimal correctly?
The item format varies by version (Season 6 uses 32 bytes; modern seasons use larger blobs). Use a reference table for your Season and a builder that places the index, level, options, luck, skill, excellent, and ancient at the correct offsets.
Doesn't a webshop create a pay-to-win advantage and drive players away?
It depends on what you sell. Cosmetics, visual wings, name changes, and convenience services are usually well received. Selling full excellent items breaks the balance and empties the server over the medium term.
How do I stop a player from buying the same item twice by double-clicking?
Use an idempotency lock: generate a unique token per purchase attempt, debit the balance inside a SQL transaction, and record the order before queuing the delivery. Block repeated clicks on the front-end too.