A metin2 market system is the cash shop / item mall mechanism where the premium currency a player buys with real money (or donation points) can be spent inside the game. The hard part is not a single feature — it is safely connecting three separate worlds: the payment provider on the website, the MySQL database and the game core. In this guide I explain how a coin travels from the wallet all the way into the player's inventory, and where things can go wrong, using the architecture I run on my own server.
The three layers of the system
Don't think of a market system as one script; there are three layers talking to each other:
- Web payment layer: The player buys a package on the site and the provider (PayPal, Stripe, PaySafeCard, mobile billing) reports the result via a
callback. This layer credits the account with coins. - Database layer: The coin balance lives in the
accountdatabase. Purchases and coin movements are written to dedicated log tables. - In-game item mall: The player opens the shop, buys items with coins and the item is delivered safely to their inventory.
Trying to wire these three layers directly together (for example, letting the web write items into the player.item table) is the most common fatal mistake in Metin2 — we'll get to why shortly.
Database schema
Let's build the persistence layer first. We add the coin balance to the account table and create a transaction log so every movement is auditable. In the account database:
ALTER TABLE account.account
ADD COLUMN `coins` BIGINT UNSIGNED NOT NULL DEFAULT 0;
CREATE TABLE account.coin_log (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`account_id` INT UNSIGNED NOT NULL,
`delta` BIGINT NOT NULL, -- + top-up, - spend
`balance_after` BIGINT UNSIGNED NOT NULL,
`reason` VARCHAR(32) NOT NULL, -- 'payment','mall_buy','refund'
`ref` VARCHAR(64) NOT NULL, -- provider tx id / item vnum
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_payment` (`reason`,`ref`),
KEY `account_id` (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
The composite uniq_payment unique key is critical: even if the same payment notification arrives twice, coins cannot be credited twice. This is called idempotency, and it is the backbone of any payment integration.
Web payment integration
When a purchase completes, the provider sends an HTTP request (webhook/callback) to your server. The rules are clear: never trust a "success" parameter coming from the browser; always verify the provider's signature/IPN on the server side. A typical PHP callback handler looks like this:
<?php
// 1) Verify the provider signature (example: HMAC)
$payload = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $payload, PROVIDER_SECRET);
if (!hash_equals($expected, $sig)) {
http_response_code(403);
exit('invalid signature');
}
$data = json_decode($payload, true);
$txid = $data['transaction_id'];
$status = $data['status'];
$amount = (int) $data['coins']; // coins per package
$login = $data['account_login'];
if ($status !== 'completed' || $amount <= 0) {
exit('ignored');
}
// 2) Idempotent credit — inside a single transaction
$pdo->beginTransaction();
try {
$acc = $pdo->prepare('SELECT id FROM account WHERE login = ? FOR UPDATE');
$acc->execute([$login]);
$accountId = $acc->fetchColumn();
if (!$accountId) throw new Exception('account not found');
// thanks to UNIQUE(reason,ref) a repeated txid errors out
$log = $pdo->prepare(
'INSERT INTO coin_log (account_id, delta, balance_after, reason, ref, created_at)
SELECT ?, ?, coins + ?, "payment", ?, NOW() FROM account WHERE id = ?');
$log->execute([$accountId, $amount, $amount, $txid, $accountId]);
$pdo->prepare('UPDATE account SET coins = coins + ? WHERE id = ?')
->execute([$amount, $accountId]);
$pdo->commit();
} catch (PDOException $e) {
$pdo->rollBack();
// 23000 = duplicate key → payment already processed, that's fine
if ($e->getCode() !== '23000') { http_response_code(500); }
}
echo 'OK';
The FOR UPDATE row lock queues two concurrent notifications for the same account; the UNIQUE constraint blocks double crediting. Together these two mechanisms defeat race conditions and replay attacks.
The in-game item mall and the "DB cache" trap
Now the most critical part. While a player is online, the Metin2 game core keeps their inventory in memory (in the DB cache layer). If the web writes an item directly into the player.item table, the game core overwrites it with its own memory copy on logout and your item is lost. That is why coin/item delivery must always go through the game core.
The safe pattern is a delivery queue: the web only writes a "give this account this item" record into a table; the game processes that record while the player is online and delivers the item through the official API.
CREATE TABLE player.item_award (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`account_id` INT UNSIGNED NOT NULL,
`item_vnum` INT UNSIGNED NOT NULL,
`count` INT UNSIGNED NOT NULL DEFAULT 1,
`claimed` TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `claimed` (`account_id`, `claimed`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
When the player spends coins in the shop (this can be driven by a source-level item shop window or by a quest NPC), two things happen: account.coins is reduced and a row is inserted into item_award. Delivery is then handled on the quest side with a periodic check or a login event:
quest cash_delivery begin
state start begin
when login begin
-- check pending rewards on each login (example logic)
local rows = mysql_direct_query(
"SELECT id, item_vnum, count FROM player.item_award "..
"WHERE account_id = "..pc.get_account_id()..
" AND claimed = 0 LIMIT 10")
for i, row in ipairs(rows) do
pc.give_item2(tonumber(row.item_vnum), tonumber(row.count))
mysql_direct_query(
"UPDATE player.item_award SET claimed = 1 WHERE id = "..row.id)
end
end
end
end
Here pc.give_item2 places the item into the player's real inventory (synced between memory and DB); if the inventory is full you can drop it into the mailbox with the standard logic. Reduce the coins and write the item_award row inside a single MySQL transaction, so an interrupted purchase neither takes the coins without giving the item nor the reverse.
Security and fraud prevention
Once money is involved, the market system is your attack surface. Do not negotiate on these items:
- Server-side validation: Coins are always triggered by the provider's verified callback — no client-supplied data grants authority on its own.
- Idempotency: Repeated notifications (providers resend the same webhook several times on network errors) are absorbed by the
UNIQUEkey. - Negative/overflow checks: Coin amount and price are validated server-side; never trust a price sent by the client.
- Chargebacks: For PayPal/credit-card refunds, build a
reason='refund'flow that claws the coins back; otherwise fraudsters mint free coins. - Audit trail:
coin_logis the proof of every movement; in a dispute you read the account's entire history from there.
Frequently Asked Questions
Can't I just write the item straight into the player's inventory?
Not while the player is online. The game core keeps the inventory in memory; a direct MySQL write is overwritten on logout and lost. The delivery queue + pc.give_item2 combination is the only safe way.
What happens if the payment provider sends the webhook twice?
Nothing — which is exactly what you want. The UNIQUE(reason, ref) constraint on coin_log rejects the second record, so coins are credited only once. This is idempotency.
Should I build the item mall with a quest or in the source?
For small/medium servers a quest-based item shop is fast and easy to maintain. If you want a storefront with hundreds of items, pages and filters, a custom mall window at the source level gives a smoother experience; but the architecture (coin → queue → delivery) is the same either way.
Want to set up your market system safely? I build the whole chain — from web payment integration to the in-game item mall — tailored to your server. Let's talk about your project: get in touch with me.