14.4 Live-Ops, กาชา และเศรษฐกิจในเกม

เฟส 14 · เน็ตเวิร์กและ Live-Service · เวลาเรียน: 30–50 h

การรันเกมเป็นบริการ — อีเวนต์, การอัปเดต content, ระบบกาชาและรางวัล และเทคโนโลยีเศรษฐกิจเบื้องหลังการทำเงิน

หัวข้อก่อนหน้าของบทนี้พูดถึงเกมที่เราส่งมอบให้ผู้เล่นในวันแรกที่เปิดตัว หัวข้อนี้พูดถึงสิ่งที่เกิดขึ้นหลังจากนั้น เกมแบบ live game จะเปลี่ยนแปลงต่อเนื่องหลังเปิดตัว สัปดาห์แล้วสัปดาห์เล่า เป็นเวลาหลายปี โดยผู้เล่นไม่ต้องติดตั้งเกมใหม่เลยสักครั้ง เกม Genshin Impact และ Honkai: Star Rail ของ HoYoverse ทำงานตามโมเดลนี้เป๊ะ ๆ คือมีเนื้อเรื่องบทใหม่ ตัวละครเล่นได้ตัวใหม่ และ event จำกัดเวลาใหม่ ๆ ตามตารางที่แน่นอน โดยรายได้ส่วนใหญ่มาจากระบบ gacha ที่ขายการสุ่มตัวละครและอาวุธ หัวข้อนี้จะอธิบายว่าบริการต่อเนื่องแบบนี้ถูกสร้างขึ้นมาอย่างไรจริง ๆ ตั้งแต่คอนเทนต์ที่ส่งผ่าน server config แทนที่จะเขียนโค้ดใหม่ การสุ่ม gacha ที่ต้องรันบนเซิร์ฟเวอร์เท่านั้น ห้ามรันบนเครื่องผู้เล่นเด็ดขาด ระบบ pity ที่กำหนดเพดานสูงสุดของโชคร้าย เศรษฐกิจสกุลเงินที่รักษาราคาให้คงที่ได้นานหลายปี และกฎระเบียบที่สตูดิโอต้องทำตามตลอดกระบวนการทั้งหมดนี้

1. Live-Ops คืออะไร

Live-ops (ย่อมาจาก live operations) หมายถึงการดูแลเกมในฐานะบริการที่ดำเนินต่อเนื่องไปเรื่อย ๆ แทนที่จะเป็นสินค้าที่ขายครั้งเดียวจบ เกมกล่องสมัยก่อนที่ขายเป็นแผ่นดิสก์จะ "จบงาน" ตั้งแต่วันที่วางขาย อาจมี patch ตามมาอีกหนึ่งหรือสองครั้งแล้วก็หยุด เกม live-ops อย่าง Genshin Impact หรือ Honkai: Star Rail ไม่มีวัน "จบ" ในความหมายนั้น เวอร์ชันที่ติดตั้งอยู่ในมือถือผู้เล่นวันนี้ ในอีกหกเดือนข้างหน้าจะต้องหน้าตาต่างไปจากเดิม มีตัวละครใหม่ เนื้อเรื่องบทใหม่ event จำกัดเวลาใหม่ ๆ การปรับบาลานซ์ใหม่ ๆ ทยอยมาตามตารางที่แน่นอน ต่อเนื่องกันหลายปี โดยที่ผู้เล่นไม่ต้องซื้อเกมใหม่อีกเลย

สิ่งนี้เปลี่ยนว่าใครทำงานกับเกมและทำนานแค่ไหน ทีมของเกมกล่องส่วนใหญ่จะยุบตัวหลังจากส่งมอบงานเสร็จ แต่เกม live-ops จะคงทีมงานทั้งชุดไว้ทำงานต่อแบบไม่มีกำหนดสิ้นสุด live-ops producer วางแผนว่าจะปล่อยอะไรและเมื่อไหร่ ดีไซเนอร์สร้างคอนเทนต์ใหม่ วิศวกรฝั่งเซิร์ฟเวอร์ดูแลระบบหลังบ้านให้ทำงานต่อเนื่อง และนักวิเคราะห์ข้อมูลคอยดูว่าผู้เล่นตอบสนองต่อการเปลี่ยนแปลงแต่ละอย่างอย่างไร ตัวเกมเองคือสินค้า แต่ตารางอัปเดตที่ดำเนินต่อเนื่องต่างหากที่ทำให้ผู้เล่นกลับมาเล่นซ้ำ และทำให้เกมยังคงมีรายได้เข้ามาต่อเนื่องไปอีกนานหลังเปิดตัว

Tip วิธีง่าย ๆ ที่จะแยกเกม live-ops ออกจากเกมกล่อง คือลองถามว่าถ้าเซิร์ฟเวอร์ปิดตัวลงจะเกิดอะไรขึ้น เกมกล่องแบบ single-player อย่าง RPG คลาสสิกยังเล่นแบบออฟไลน์ได้สบาย ๆ แต่เกม live-ops อย่าง Genshin Impact จะเล่นไม่ได้ทันทีที่เซิร์ฟเวอร์ดับ เพราะ event, gacha และบ่อยครั้งแม้แต่ความคืบหน้าของเนื้อเรื่องก็ต้องพึ่งการเชื่อมต่อกับ backend ที่เปลี่ยนแปลงอยู่ตลอดเวลา

2. จังหวะการปล่อยคอนเทนต์: Patch, Event, Season, Banner

ทีม live-ops ไม่ได้ปล่อยคอนเทนต์ตามใจเมื่อไหร่ที่ทำเสร็จพอดี แต่ปล่อยตาม cadence (จังหวะการปล่อยที่แน่นอนและวนซ้ำเป็นรอบ) ที่คงที่และคาดเดาได้ เพราะผู้เล่นวางแผนเวลาเล่นของตัวเองตามจังหวะนี้ และรายได้ของเกมก็ขึ้นอยู่กับว่าจังหวะนี้จะไม่มีวันขาดตอน มีคำสี่คำที่อธิบายชิ้นส่วนของตารางนี้ได้:

ทั้งสี่ชิ้นนี้จะซ้อนทับกันและวนซ้ำเป็นรอบ ตัวอย่างช่วงหกสัปดาห์ทั่วไปอาจหน้าตาแบบนี้:

week: 1 2 3 4 5 6 patch: [-- new playable character + story chapter shipped --] banner: [--- Character A banner ---][--- Character B banner ---] event: [-- side event --] [-- side event --] season: [------------------- Season pass, resets week 6 -----------------]

ไม่มีอะไรในนี้ที่สุ่มเอาเลย ทุกกล่องในไดอะแกรมนี้ถูกทีม live-ops วางตารางล่วงหน้าเป็นสัปดาห์หรือเป็นเดือน บ่อยครั้งวางแผนล่วงหน้าไปทั้ง season หรือสอง season เพื่อให้ทีมอาร์ต ทีมเขียนบท ทีมแปล และทีม QA มีเวลาทำงานเสร็จก่อนถึงวันที่กำหนดไว้ในปฏิทิน ความรู้สึกของผู้เล่นที่ว่า "มีอะไรใหม่ให้ทำตลอดเวลา" คือผลโดยตรงจากการวางแผนแบบนี้ ไม่ใช่เรื่องบังเอิญ

3. คอนเทนต์แบบ Data-Driven: ปล่อย Event โดยไม่ต้องเขียนโค้ดใหม่

การปล่อย patch ใหม่ให้ client เป็นกระบวนการที่ช้า ต้องคอมไพล์ build ต้องให้ QA ทดสอบ และบนมือถือต้องผ่านการรีวิวจาก app store ของ Apple หรือ Google ซึ่งอาจใช้เวลาตั้งแต่ไม่กี่ชั่วโมงไปจนถึงหลายวัน แถมยังมีโอกาสถูกปฏิเสธได้เฉย ๆ ด้วย ถ้าการหมุน banner ทุกสองสัปดาห์และ event ทุกสัปดาห์ต้องส่งเรื่องเข้า app store ใหม่ทั้งหมดทุกครั้ง ทีม live-ops จะไม่มีทางตามจังหวะ cadence จากหัวข้อ 2 ได้ทัน

ทางแก้คือ data-driven content คือออกแบบเกมให้ดีไซเนอร์สามารถเปิด event ใหม่ ปรับตัวละครที่ชูใน banner หรือเปลี่ยน drop rate ได้ด้วยการแก้ data (config ธรรมดา ๆ เป็นตัวเลข ข้อความ หรือ JSON) ที่ client ดาวน์โหลดตอนรันไทม์ แทนที่จะต้องเขียน code ใหม่ (โลจิกที่คอมไพล์แล้วต้องส่งไปพร้อมกับตัวแอปและผ่านการรีวิว) client จะถูกปล่อยออกไปครั้งเดียวพร้อมระบบทั่วไปที่ใช้ซ้ำได้ อย่างเช่น "ระบบ banner" "ระบบ event" "ระบบ battle pass" แล้วระบบเหล่านี้ก็อ่าน config อะไรก็ตามที่เซิร์ฟเวอร์ส่งมาให้ ณ ขณะนั้น


// A general-purpose event system, shipped once inside the client.
// It never mentions a specific event by name -- it just reads
// whatever EventConfig the server currently sends it.
[Serializable]
public class EventConfig
{
    public string EventId;
    public string DisplayName;
    public long StartUnixTime;
    public long EndUnixTime;
    public string BannerImageUrl;
    public List<string> FeaturedItemIds;
}

public class EventRunner
{
    private List<EventConfig> activeEvents = new List<EventConfig>();

    public void LoadEvents(List<EventConfig> configsFromServer, long nowUnixTime)
    {
        activeEvents.Clear();
        foreach (EventConfig cfg in configsFromServer)
        {
            if (nowUnixTime >= cfg.StartUnixTime && nowUnixTime < cfg.EndUnixTime)
            {
                activeEvents.Add(cfg);
            }
        }
    }

    public void PrintActiveEvents()
    {
        foreach (EventConfig e in activeEvents)
        {
            Console.WriteLine($"Active: {e.DisplayName} (id={e.EventId})");
        }
    }
}

ไล่ trace ทีละขั้น: สมมติเซิร์ฟเวอร์ส่ง event มาสองอัน คือ "Lantern Festival" ที่รันตั้งแต่เวลา 1000 ถึง 2000 กับ "Winter Trials" ที่รันตั้งแต่ 2500 ถึง 3500 client เรียก LoadEvents โดยตั้งเวลาปัจจุบันเป็น 1500:


var configs = new List<EventConfig> {
    new EventConfig { EventId="lantern", DisplayName="Lantern Festival",
                       StartUnixTime=1000, EndUnixTime=2000, FeaturedItemIds=new List<string>() },
    new EventConfig { EventId="winter", DisplayName="Winter Trials",
                       StartUnixTime=2500, EndUnixTime=3500, FeaturedItemIds=new List<string>() },
};

var runner = new EventRunner();
runner.LoadEvents(configs, nowUnixTime: 1500);
runner.PrintActiveEvents();

ผลลัพธ์ที่คาดหวัง:


Active: Lantern Festival (id=lantern)

มีแค่ Lantern Festival ที่ active ตอนเวลา 1500 เพราะ 1500 อยู่ระหว่างเวลาเริ่มกับเวลาจบของมัน แต่ยังมาไม่ถึงเวลาที่ Winter Trials จะเริ่ม ไม่มีใครต้องเขียน if (currentEvent == "Lantern Festival") ไว้ที่ไหนใน EventRunner เลย คลาสเดียวกันนี้จะรัน event ทุกอันที่ทีมดีไซน์คิดขึ้นในอนาคตได้ตลอดไป ตราบใดที่อธิบายมันออกมาเป็น EventConfig ได้ การปิด Lantern Festival ในสัปดาห์หน้าแล้วเปิด event อื่นที่ต่างไปโดยสิ้นเชิง ก็แค่เปลี่ยน data บนเซิร์ฟเวอร์ มีผลใช้งานจริงภายในไม่กี่นาที โดยไม่ต้องเขียนโค้ด client ใหม่เลยสักบรรทัด และไม่ต้องผ่านการรีวิวจาก app store เลย

Common mistake การ hardcode event ใหม่เป็น special case ไว้ใน client คือเขียนโค้ดบล็อกหนึ่งที่มีไว้จัดการ banner หรือ event อันนี้อันเดียวโดยเฉพาะ วิธีนี้ใช้ได้กับ event แรก แต่ event ที่สองก็ต้องมีบล็อก special case ของตัวเองอีก พอถึง event ที่สิบ client ก็เต็มไปด้วยโค้ดเฉพาะกิจจนไม่มีใครกล้าแก้อะไรได้อย่างปลอดภัยอีกต่อไป ทางแก้จะเหมือนเดิมเสมอ คือหารูปแบบทั่วไปให้เจอ (เช่น "สิ่งที่มีเวลาเริ่ม เวลาจบ และไอเทมที่ชู") แล้วสร้างระบบเดียวรองรับรูปแบบนั้น จากนั้นก็ป้อน data ที่ต่างกันเข้าไปได้เรื่อย ๆ ตลอดไป

4. Remote Config และ Feature Flag

Remote config คือชื่อเรียกรวม ๆ ของการตั้งค่าใด ๆ ที่ client ดึงมาจากเซิร์ฟเวอร์ตอนรันไทม์ แทนที่จะฝังไว้ตายตัวใน build ลิสต์ EventConfig จากหัวข้อ 3 ก็เป็นตัวอย่างหนึ่ง แต่แนวคิดเดียวกันนี้ครอบคลุมถึง drop rate ของ gacha ราคาในร้านค้า และการปรับความยากด้วย Feature flag (สวิตช์เปิด/ปิดที่มีชื่อเรียก ควบคุมจากเซิร์ฟเวอร์ ใช้ตัดสินว่าโค้ดที่ปล่อยไปแล้วชิ้นหนึ่งจะทำงานหรือไม่) คืออีกครึ่งหนึ่งของแนวคิดเดียวกันนี้ โค้ดของฟีเจอร์หนึ่งสามารถถูกส่งไปพร้อมกับ client ได้ล่วงหน้าหลายสัปดาห์ก่อนที่จะถึงเวลาให้เห็น โดยนอนรออยู่เฉย ๆ จนกว่าเซิร์ฟเวอร์จะเปิด flag ของมัน


public class FeatureFlags
{
    private Dictionary<string, bool> flags;

    public FeatureFlags(Dictionary<string, bool> flagsFromServer)
    {
        flags = flagsFromServer;
    }

    public bool IsEnabled(string flagName)
    {
        return flags.TryGetValue(flagName, out bool value) && value;
    }
}

// Later, anywhere in the client:
var flags = new FeatureFlags(new Dictionary<string, bool> {
    { "winter_event_2026", false },
    { "new_banner_ui", true },
});

if (flags.IsEnabled("winter_event_2026"))
{
    Console.WriteLine("Showing Winter Event button");
}
else
{
    Console.WriteLine("Winter Event button hidden");
}

ผลลัพธ์ที่คาดหวัง:


Winter Event button hidden

โค้ดและ asset ของ winter event สามารถนอนอยู่ในแอปที่ติดตั้งไว้แล้วตอนนี้ได้เลย เพียงแต่ flag ยังปิดอยู่ พอถึงวันเปิดตัว ทีม live-ops ก็แค่เปลี่ยน winter_event_2026 เป็น true บนเซิร์ฟเวอร์ แล้ว client ทุกเครื่องที่เชื่อมต่ออยู่ก็จะเริ่มแสดงมันภายในไม่กี่นาที โดยไม่ต้องดาวน์โหลดอะไรใหม่ ไม่ต้องผ่านการรีวิวจาก app store เพราะตัว client เองไม่ได้เปลี่ยนอะไรเลย feature flag ยังทำให้สตูดิโอทำ staged rollout ได้ด้วย คือเปิด flag ให้ผู้เล่นแค่ 5% ก่อน คอยดูว่ามี crash หรือคำร้องเรียนไหม แล้วค่อยขยับขึ้นไปถึง 100% เมื่อดูปลอดภัยดีแล้ว และยังทำหน้าที่เป็น kill switch ได้ถ้ามีอะไรผิดพลาดขึ้นมา แค่เปลี่ยน flag ตัวเดิมกลับเป็น false ฟีเจอร์ที่พังก็จะหายไปทันที โดยไม่ต้องออก patch ฉุกเฉิน

เมื่อรวมหัวข้อ 3 กับ 4 เข้าด้วยกัน จะได้ live-ops content pipeline ฉบับเต็ม คือเส้นทางที่ event หรือ banner ใหม่เดินทางจากแผนของดีไซเนอร์ไปจนถึงหน้าจอผู้เล่น:

[Designers / live-ops team] | | author event data, banner data, drop rates, flags v [Live-ops CMS / backend tool] | | publish v [Config service] --------------------> [CDN] | | | (also stores flag state) | client downloads config v v [Game server] [Game client on player device] | | | server enforces rules | reads EventConfig list, | (Section 5 onward: gacha rolls, | checks feature flags, | currency, purchases all happen | shows/hides UI accordingly | here, never on the client) | v v [Player receives results, sees active events/banners] | v [Telemetry / analytics] ------ feeds back to ------> [Designers / live-ops team]

มีสองลูปที่สำคัญตรงนี้ ลูปบนคือวิธีที่คอนเทนต์ถูกส่งออกไป ดีไซเนอร์เขียน data ไม่ใช่ code แล้วมันก็ไปถึง client ผ่าน config service และ CDN (content delivery network คือเซิร์ฟเวอร์ที่กระจายอยู่ทั่วโลกเพื่อส่งไฟล์ให้ผู้เล่นที่อยู่ใกล้ ๆ ได้เร็วขึ้น) ลูปล่างคือวิธีที่ทีมรู้ว่าคอนเทนต์นั้นได้ผลหรือไม่ ทุกการกระทำของผู้เล่นจะถูกบันทึกเป็น telemetry แล้วไหลย้อนกลับไปหาดีไซเนอร์คนเดิมที่วางแผน event นั้น ปิดลูปให้ครบวงจร หัวข้อ 11 จะพูดถึง feedback loop นี้แบบละเอียด

5. ระบบ Gacha และทำไมการสุ่มต้องรันบนเซิร์ฟเวอร์

ระบบ gacha (ตั้งชื่อตามเครื่องหยอดเหรียญกาชาปองของญี่ปุ่น) เป็นกลไกที่ผู้เล่นเสียเงินในเกมเพื่อรับไอเทมสุ่มหนึ่งชิ้นจาก pool ที่กำหนดไว้ โดยมีการเปิดเผยโอกาสได้รับต่อ rarity tier (หมวดหมู่อย่าง 3-star, 4-star, 5-star ที่จัดกลุ่มไอเทมตามความหายากและความแรง) นี่คือกลไกทำเงินหลักเบื้องหลังเกมอย่าง Genshin Impact และ Honkai: Star Rail ผู้เล่น pull เพื่อลุ้นได้ตัวละครหรืออาวุธเฉพาะตัว แทนที่จะซื้อโดยตรง

กฎทางวิศวกรรมที่สำคัญที่สุดสำหรับระบบ gacha คือ การสุ่มต้องเกิดขึ้นบนเซิร์ฟเวอร์เท่านั้น ห้ามรันบนเครื่องผู้เล่นเด็ดขาด ลองคิดดูว่าถ้าสุ่มฝั่ง client จะเกิดอะไรขึ้น:

CLIENT-SIDE ROLL (wrong): player taps "Pull" | v client's own code picks the random result | v client tells the server "I got a 5-star, please give it to me" | v a modified client can just always send "I got a 5-star" -- the server has no way to tell a real roll from a fabricated message
SERVER-SIDE ROLL (correct): player taps "Pull" | v client sends only a request: "spend N currency, roll on banner X" | v SERVER checks the player has enough currency, SERVER picks the random result, SERVER updates the pity counter, SERVER deducts currency and grants the item -- all in one operation | v server sends the already-decided result back to the client, which only has to display it

client ของเกมแบบนี้ปกติจะรันอยู่บนอุปกรณ์ที่สตูดิโอควบคุมไม่ได้เต็มร้อย นั่นคือมือถือหรือ PC ของผู้เล่นเอง ซึ่งผู้เล่นสามารถส่องดูได้ด้วย debugger, memory editor หรือ network client ที่ถูกแก้ไข การตัดสินใจใด ๆ ที่ทำอยู่บน client ล้วน ๆ (การสุ่มผลลัพธ์ ไอเทมได้รับหรือไม่ ใช้เงินไปเท่าไหร่) สามารถถูกแก้ไขแทรกแซงได้ด้วยเครื่องมือแบบนั้นเป๊ะ ๆ นี่คือบทเรียนเดียวกันกับที่บทเครือข่ายก่อนหน้านี้เรียกว่าดีไซน์แบบ server-authoritative (เซิร์ฟเวอร์ถือสำเนาที่แท้จริงของสถานะเกมเพียงชุดเดียว client ทำได้แค่ขอให้เปลี่ยนแปลง ไม่มีสิทธิ์เปลี่ยนแปลงเองโดยตรง) ระบบ gacha เป็นหนึ่งในจุดที่เดิมพันสูงที่สุดในการเอากฎนี้ไปใช้ เพราะมีเงินจริงเข้ามาเกี่ยวข้อง

Common mistake "ให้ client สุ่มเอง แล้วเซิร์ฟเวอร์แค่ตรวจสอบย้อนหลังว่าผลลัพธ์ดูสมเหตุสมผลไหม" วิธีนี้ยังพังอยู่ดี เพราะ client ที่ถูกแก้ไขสามารถส่งผลลัพธ์ที่ดูสมเหตุสมผลได้สบาย ๆ (เช่นส่งไอเทม 3-star ทั่วไปตามปกติ จนกระทั่งมันตัดสินใจส่ง 5-star ทุก ๆ ไม่กี่ pull ในอัตราที่การตรวจสอบความสมเหตุสมผลของเซิร์ฟเวอร์จับไม่ได้) วิธีเดียวที่ปลอดภัยจริง ๆ คือให้เซิร์ฟเวอร์เป็นคนสุ่มเลขและตัดสินผลลัพธ์เองทั้งหมด โดยไม่ต้องให้ client รายงานว่าเกิดอะไรขึ้น เพราะมันไม่เคยเป็นคนตัดสินใจตั้งแต่แรกอยู่แล้ว

6. การสุ่มแบบถ่วงน้ำหนัก (Weighted Random) และการเปิดเผย Drop Rate

pool ของ gacha ไม่ใช่การโยนเหรียญง่าย ๆ ที่ทุกไอเทมมีโอกาสเท่ากัน แต่แต่ละไอเทม หรือที่พบบ่อยกว่าคือแต่ละ rarity tier จะมี weight เป็นของตัวเอง (ตัวเลขที่เป็นสัดส่วนกับโอกาสที่ไอเทมนั้นจะถูกสุ่มได้ weight ไม่จำเป็นต้องรวมกันได้ 100 สนใจแค่อัตราส่วนระหว่างกัน) และสตูดิโอต่าง ๆ ก็ถูกเรียกร้องมากขึ้นเรื่อย ๆ ให้เปิดเผย drop rate ที่คำนวณได้ (ความน่าจะเป็นจริง ๆ ปกติแสดงเป็นเปอร์เซ็นต์ ของการได้รับไอเทมจาก tier หนึ่งใน 1 pull) ไว้ในเกมโดยตรง หัวข้อ 12 จะอธิบายว่าทำไมการเปิดเผยนี้ถึงไม่ใช่แค่มารยาทเฉย ๆ

วิธีมาตรฐานที่ใช้แปลงลิสต์ของ weight ให้กลายเป็นการสุ่มเลือกหนึ่งชิ้น คือวิธี cumulative weight เอา weight ของแต่ละไอเทมมาเรียงต่อกันไปตามเส้นจำนวน สุ่มจุดหนึ่งบนเส้นนั้น แล้วดูว่าจุดนั้นตกอยู่ในช่วงของไอเทมไหน


public class GachaItem
{
    public string Id;
    public string Rarity;   // "3-star", "4-star", "5-star"
    public int Weight;      // relative weight, not a percentage
}

public class GachaPool
{
    public List<GachaItem> Items = new List<GachaItem>();

    public int TotalWeight()
    {
        int sum = 0;
        foreach (GachaItem item in Items) sum += item.Weight;
        return sum;
    }

    // roll is an integer already chosen in [0, TotalWeight()) by the caller --
    // kept separate from the random number generator itself so this method
    // can be tested with an exact, known input (see the worked trace below).
    public GachaItem PickByRoll(int roll)
    {
        int cursor = 0;
        foreach (GachaItem item in Items)
        {
            cursor += item.Weight;
            if (roll < cursor) return item;
        }
        throw new InvalidOperationException("roll was outside total weight");
    }
}

ไล่ trace ทีละขั้น: pool เล็ก ๆ ที่มีสามไอเทม weight เป็น 940, 51, 9 (รวม weight ทั้งหมด 1000) ป้อนค่า roll ที่กำหนดไว้แน่นอนสี่ค่าแทนเลขสุ่มจริง เพื่อให้ตรวจผลลัพธ์ทุกอันด้วยมือได้:


var pool = new GachaPool();
pool.Items.Add(new GachaItem { Id="common_sword",  Rarity="3-star", Weight=940 });
pool.Items.Add(new GachaItem { Id="rare_bow",      Rarity="4-star", Weight=51 });
pool.Items.Add(new GachaItem { Id="legendary_gem", Rarity="5-star", Weight=9 });

int[] scriptedRolls = { 100, 950, 991, 945 };
foreach (int roll in scriptedRolls)
{
    GachaItem result = pool.PickByRoll(roll);
    Console.WriteLine($"roll={roll} -> {result.Id} ({result.Rarity})");
}

ผลลัพธ์ที่คาดหวัง:


roll=100 -> common_sword (3-star)
roll=950 -> rare_bow (4-star)
roll=991 -> legendary_gem (5-star)
roll=945 -> rare_bow (4-star)

ลองไล่ด้วยมือดูว่าทำไม: ไอเทมทั้งสามชิ้นครอบครองช่วง [0, 940) สำหรับ 3-star, [940, 991) สำหรับ 4-star, และ [991, 1000) สำหรับ 5-star เพราะช่วงของแต่ละไอเทมจะเริ่มตรงจุดที่ cursor ของไอเทมก่อนหน้าหยุดไว้ roll ที่ได้ 100 จะตกอยู่กลาง ๆ ช่วงแรกสบาย ๆ roll ที่ได้ 950 เกิน 940 มาแล้วแต่ยังไม่ถึง 991 จึงตกอยู่ในช่วงของ 4-star roll ที่ได้ 991 ตกอยู่พอดีบนขอบเขต ซึ่งเงื่อนไข roll < cursor จะจัดให้อยู่ในช่วงของ 5-star (991 ไม่น้อยกว่า 991 ซึ่งเป็น cursor ของ 4-star แต่มันน้อยกว่า 1000 ซึ่งเป็น cursor ของ 5-star) เซิร์ฟเวอร์จริงจะสุ่มเลขจำนวนเต็มนี้ด้วย random number generator ที่เหมาะสม โดย seed มาจากแหล่งที่ปลอดภัย กระจายสม่ำเสมอในช่วง [0, TotalWeight()) แต่ตัวโลจิกการเลือกเองก็คือการเดินแบบ cumulative-weight นี้เป๊ะ ๆ ทุกครั้ง

การแปลง weight เหล่านี้ให้เป็น drop rate ที่สตูดิโอเอาไปเปิดเผยจริง ๆ ก็แค่หารเลขครั้งเดียว 9 จาก weight รวม 1000 คือ base rate 0.9% สำหรับ tier 5-star ใน 1 pull ตัวเลขตัวเดียวนี้ — 0.9% — คือสิ่งที่กฎระเบียบในหัวข้อ 12 บังคับให้บางสตูดิโอต้องพิมพ์ไว้บนหน้าจอ pull โดยตรง

7. Pity Counter: การการันตีไอเทมหายากภายใน N Pull

การสุ่มแบบ weighted ล้วน ๆ ที่อัตรา 0.9% มีจุดที่แย่อยู่ในตัวเอง คือในทางคณิตศาสตร์เป็นไปได้ (แม้จะไม่น่าจะเกิด) ที่จะ pull ติดกันสองร้อยครั้งโดยไม่เจอ 5-star เลยสักครั้ง pity counter (ตัวนับจำนวน pull สะสมตั้งแต่ได้ไอเทมหายากครั้งล่าสุด ใช้ดันโอกาสให้สูงขึ้น หรือสุดท้ายก็การันตีผลลัพธ์เมื่อสตรีคนั้นยาวขึ้นเรื่อย ๆ) มีไว้เพื่อกำหนดเพดานสูงสุดของโชคร้ายแบบนี้โดยเฉพาะ ปกติจะมีสองเกณฑ์ทำงานร่วมกัน:

pull requested | v pity_counter = pity_counter + 1 | v pity_counter >= HARD_PITY (90)? ---yes---> rate = 100% (guaranteed) | no v pity_counter >= SOFT_PITY (74)? ---yes---> rate = BASE_RATE + ramp(pity_counter) | no v rate = BASE_RATE (0.9%) | v roll a random number in [0.0, 1.0) | v roll < rate? ---yes---> grant 5-star, RESET pity_counter to 0 | no v grant a lower-rarity item, KEEP pity_counter as it is

เขียนเป็นโค้ด C# ต้นไม้การตัดสินใจแบบเดียวกันนี้จะหน้าตาแบบนี้:


public class PityState
{
    public int PullsSinceLastFiveStar = 0;
}

public class PityConfig
{
    public double BaseFiveStarRate = 0.009; // 0.9%, matches Section 6's pool
    public int SoftPityStart = 74;          // rate starts ramping on this pull
    public int HardPityAt = 90;             // this pull is always a 5-star
    public double SoftPityStep = 0.06;      // rate added per pull inside soft pity
}

public class PityGacha
{
    // nextRoll returns a value in [0.0, 1.0); kept as a function parameter
    // so the worked trace below can feed it exact, known numbers.
    public static string RollOnePull(PityState pity, PityConfig cfg, Func<double> nextRoll)
    {
        int thisPullNumber = pity.PullsSinceLastFiveStar + 1;
        double rate = cfg.BaseFiveStarRate;

        if (thisPullNumber >= cfg.HardPityAt)
        {
            rate = 1.0;
        }
        else if (thisPullNumber >= cfg.SoftPityStart)
        {
            int stepsIntoSoftPity = thisPullNumber - cfg.SoftPityStart;
            rate = cfg.BaseFiveStarRate + stepsIntoSoftPity * cfg.SoftPityStep;
            if (rate > 1.0) rate = 1.0;
        }

        double roll = nextRoll();
        bool gotFiveStar = roll < rate;

        if (gotFiveStar)
        {
            pity.PullsSinceLastFiveStar = 0;
            return $"5-star! (pull #{thisPullNumber}, rate was {rate:P1})";
        }
        else
        {
            pity.PullsSinceLastFiveStar = thisPullNumber;
            return $"no 5-star (pull #{thisPullNumber}, rate was {rate:P1})";
        }
    }
}

ไล่ trace ทีละขั้น: ผู้เล่นคนหนึ่ง pity counter เดินมาแล้ว 88 pull (โชคร้ายมาตลอด) เราป้อนค่า roll ที่กำหนดไว้สามค่าเพื่อดูว่า pull สามครั้งถัดไปจะเป็นยังไง:


var pity = new PityState { PullsSinceLastFiveStar = 88 };
var cfg = new PityConfig();

double[] scriptedRolls = { 0.95, 0.95, 0.5 };
int i = 0;
Func<double> nextRoll = () => scriptedRolls[i++];

for (int n = 0; n < 3; n++)
{
    Console.WriteLine(PityGacha.RollOnePull(pity, cfg, nextRoll));
}

ผลลัพธ์ที่คาดหวัง:


no 5-star (pull #89, rate was 90.9%)
5-star! (pull #90, rate was 100.0%)
no 5-star (pull #1, rate was 0.9%)

pull ที่ 89 อยู่ในช่วง soft pity แล้ว (ผ่าน pull 74 มา 15 ก้าว ทำให้ rate ไต่ขึ้นไปแล้วเป็น 0.9% + 15 * 6% = 90.9%) แต่ roll ที่ได้ 0.95 ก็ยังไม่ต่ำกว่า rate ที่สูงขนาดนั้นอยู่ดี เลยพลาด นี่พิสูจน์ว่า soft pity แค่ดันโอกาสให้สูงขึ้น แต่ไม่ได้การันตีอะไรด้วยตัวมันเอง pull ที่ 90 ชน hard pity พอดี ซึ่งโค้ดจะบังคับ rate = 1.0 ทำให้ roll สูงแบบเดิม (0.95) กลายเป็นชนะแทน เพราะ 0.95 < 1.0 เป็นจริงเสมอไม่ว่า roll จะได้อะไร counter จะรีเซ็ตกลับเป็น 0 แล้ว pull ที่สามก็เริ่มนับใหม่ที่ base rate ปกติ 0.9% ซึ่ง roll ที่ได้ 0.5 ก็พลาดง่าย ๆ อีกครั้ง

Tip เก็บ PullsSinceLastFiveStar ไว้บนเซิร์ฟเวอร์ ผูกไว้กับ account ของผู้เล่น เหมือนกับสกุลเงิน (หัวข้อ 9) ห้ามเก็บไว้ในค่าที่ client ส่งขึ้นมาพร้อมกับ request แต่ละครั้งเด็ดขาด ถ้า client รายงาน pity count ของตัวเองได้ client ที่ถูกแก้ไขก็แค่อ้างว่าตัวเองอยู่ที่ pull 90 ตลอดเวลาได้เลย

รวม Weighted Draw กับ Pity เข้าด้วยกัน

หัวข้อ 6 และ 7 ตอบคำถามคนละข้อกัน หัวข้อ 6 ตัดสินว่า ไอเทมชิ้นไหนเจาะจง ที่จะออกมาจาก pool เมื่อรู้ rarity tier แล้ว ส่วนหัวข้อ 7 ตัดสินว่า pull นี้จะชน tier หายากหรือไม่ การสุ่ม gacha ฝั่งเซิร์ฟเวอร์จริง ๆ จะรวมทั้งสองอย่างเข้าด้วยกัน ทำต่อกันทันทีในการทำงานครั้งเดียว:


public class GachaService
{
    // Separate weighted pools per tier -- each one uses the exact same
    // cumulative-weight PickByRoll from Section 6.
    public GachaPool FiveStarItems;
    public GachaPool StandardItems; // everything below 5-star

    public GachaItem RollPull(PityState pity, PityConfig cfg, Random rng)
    {
        int thisPullNumber = pity.PullsSinceLastFiveStar + 1;
        double rate = cfg.BaseFiveStarRate;

        if (thisPullNumber >= cfg.HardPityAt)
        {
            rate = 1.0;
        }
        else if (thisPullNumber >= cfg.SoftPityStart)
        {
            int stepsIntoSoftPity = thisPullNumber - cfg.SoftPityStart;
            rate = cfg.BaseFiveStarRate + stepsIntoSoftPity * cfg.SoftPityStep;
            if (rate > 1.0) rate = 1.0;
        }

        bool gotFiveStar = rng.NextDouble() < rate;

        if (gotFiveStar)
        {
            pity.PullsSinceLastFiveStar = 0;
            int roll = rng.Next(0, FiveStarItems.TotalWeight());
            return FiveStarItems.PickByRoll(roll);
        }
        else
        {
            pity.PullsSinceLastFiveStar = thisPullNumber;
            int roll = rng.Next(0, StandardItems.TotalWeight());
            return StandardItems.PickByRoll(roll);
        }
    }
}

โค้ดเวอร์ชันนี้ใช้ System.Random ตัวจริง (ในโปรดักชันจริง แหล่งสุ่มแบบ cryptographically secure จะปลอดภัยกว่า เพื่อไม่ให้ใครทำนาย roll ครั้งถัดไปได้จากการดู roll ที่ผ่านมา) แทนที่จะใช้ Func<double> ที่กำหนดสคริปต์ไว้แบบใน trace ด้านบน ตั้งใจให้เป็นแบบนี้ เวอร์ชันสคริปต์มีไว้แค่เพื่อให้ตัวอย่างในบทนี้ให้ผลลัพธ์ที่ตรวจสอบด้วยมือได้ ในขณะที่ GachaService.RollPull คือรูปแบบที่รันจริงบนเซิร์ฟเวอร์โปรดักชัน ถูกเรียกครั้งเดียวต่อหนึ่ง pull ทำงานอยู่ภายในโปรเซสของเซิร์ฟเวอร์ทั้งหมด โดย client จะได้รับแค่ผลลัพธ์ GachaItem ที่ตัดสินใจเสร็จแล้วเท่านั้น

8. ตัวอย่างการคำนวณความน่าจะเป็นแบบละเอียด

การลองคำนวณเลขจริง ๆ เบื้องหลังความรู้สึก "ฉันโชคร้าย" สักครั้งด้วยตัวเลขที่ทำให้ง่ายลงจะช่วยได้ แทนที่จะเชื่อความรู้สึกเฉย ๆ ลองใช้ base rate แบบเรียบ (ยังไม่มี pity เข้ามาเกี่ยว) ที่ 2% ต่อ pull แล้วถามว่า โอกาสที่จะได้ 5-star อย่างน้อยหนึ่งครั้งใน 10 pull แรกคือเท่าไหร่?

กับดักง่าย ๆ ที่คนมักตกคือเอา 2% มาบวกสิบครั้งแล้วคาดหวัง 20% ซึ่งผิด เพราะมันมองข้ามว่าแต่ละ pull เป็นเหตุการณ์อิสระจากกัน แทนที่จะทำแบบนั้น ให้ลองตั้งคำถามในมุมตรงข้ามก่อน คือโอกาสที่จะพลาดทุกครั้งในทั้ง 10 pull คือเท่าไหร่?

P(miss one pull) = 1 - 0.02 = 0.98 P(miss all 10 pulls) = 0.98 ^ 10 = 0.98 * 0.98 * 0.98 * ... (10 times) ~ 0.817 P(at least one hit in 10 pulls) = 1 - P(miss all 10) = 1 - 0.817 = 0.183 -> about 18.3%

ดังนั้นแม้จะอยู่ที่อัตรา 2% ต่อ pull สิบ pull ก็ให้โอกาสสำเร็จแค่ 18.3% ไม่ใช่ 20% และความต่างนี้จะยิ่งมากขึ้นเมื่อจำนวน pull เพิ่มขึ้น เพราะ "พลาดทุกครั้งเลย" มีโอกาสน้อยลงเรื่อย ๆ แต่ไม่ได้ลดลงเป็นเส้นตรง นี่คือเหตุผลว่าทำไม hard pity (หัวข้อ 7) ถึงต้องมีอยู่เป็นกลไกแยกต่างหาก แทนที่จะเชื่อ base rate อย่างเดียว ถ้าไม่มีมัน ผู้เล่นสัดส่วนหนึ่งที่น้อยแต่มีอยู่จริง ประมาณ 1 ใน 5 ในตัวอย่างที่ทำให้ง่ายแบบนี้ (แย่กว่านั้นอีกที่อัตราจริง 0.9%) ก็จะยังไม่ได้อะไรเลยหลังจาก pull มาเป็นจำนวนมากที่ผู้เล่นส่วนใหญ่คิดว่า "น่าจะได้แล้วนะ" โดยไม่มีการการันตีเลยว่าจะได้เมื่อไหร่ hard pity เปลี่ยนคำว่า "น่าจะได้สักวัน" ให้กลายเป็น "ได้แน่นอน ภายใน pull 90" ซึ่งเป็นคำสัญญาที่ต่างกันมากที่จะให้กับผู้เล่นที่จ่ายเงินจริง

Common mistake การมอง base rate 0.9% ว่าเป็น "1 ใน 111 ดังนั้นพอถึง 100 pull ก็ต้องได้แล้ว" แต่ละ pull เป็นเหตุการณ์อิสระ random number generator ไม่มีความจำเกี่ยวกับ pull ก่อนหน้าเลย และไม่ได้ติดหนี้ใครไว้ว่าต้องให้ชนะ สิ่งเดียวที่ทำให้โอกาสเปลี่ยนไปจริง ๆ เมื่อ pull สะสมมากขึ้น คือระบบ pity ที่จงใจดันอัตราขึ้นตามที่ออกแบบไว้ ซึ่งเป็นกฎที่ถูกออกแบบมา ไม่ใช่คุณสมบัติของความสุ่มที่ "ไล่ตามทัน" คุณ

9. การออกแบบเศรษฐกิจเสมือน (Virtual Economy)

9.1 Soft Currency กับ Premium (Hard) Currency

แทบทุกเกม live-ops จะมีสกุลเงินแยกกันอย่างน้อยสองสกุล:

การแยกสองสกุลนี้ออกจากกันทำให้สตูดิโอปรับแต่ละตัวได้อย่างอิสระ soft currency สามารถแจกได้แบบใจกว้าง เพราะมันขับเคลื่อน engagement ในชีวิตประจำวันและไม่เคยมีต้นทุนโดยตรงต่อสตูดิโอเลยที่จะแจกออกไป ส่วน premium currency ต้องคงความหายากไว้ และราคาของมันในรูปเงินจริงต้องยังมีความหมายอยู่ เพราะมันคือสินค้าตัวจริงที่ขายอยู่ ถ้าเอาสองสกุลนี้มารวมเป็นสกุลเดียว จะบีบให้ต้องเลือกอะไรที่อึดอัดใจ คือต้องทำให้รางวัลในเกมขี้เหนียว (เพื่อปกป้องมูลค่าของสกุลเงินที่จ่ายเงิน) หรือไม่ก็ทำให้สกุลเงินที่จ่ายเงินหาได้ฟรีง่ายเกินไป (ซึ่งทำลายมูลค่าของร้านค้าเอง)


public class PlayerWallet
{
    public long SoftCurrency;    // e.g. "Coins" -- earned by playing
    public long PremiumCurrency; // e.g. "Crystals" -- bought or rarely gifted

    // Two separate methods on purpose -- never one generic
    // AddCurrency(type, amount) that treats both the same way.
    public void EarnSoftCurrency(long amount)
    {
        SoftCurrency += amount;
    }

    public void GrantPremiumCurrency(long amount, string reason)
    {
        PremiumCurrency += amount;
        Console.WriteLine($"Granted {amount} premium currency: {reason}");
    }
}

var wallet = new PlayerWallet();
wallet.EarnSoftCurrency(500);
wallet.GrantPremiumCurrency(100, "first-time login bonus");
Console.WriteLine($"Coins: {wallet.SoftCurrency}, Crystals: {wallet.PremiumCurrency}");

ผลลัพธ์ที่คาดหวัง:


Granted 100 premium currency: first-time login bonus
Coins: 500, Crystals: 100

9.2 Sink และ Faucet: การรักษาสมดุลของเศรษฐกิจ

ทุกแหล่งที่เติมสกุลเงินเข้าเกมเรียกว่า faucet (รางวัลล็อกอินรายวัน การทำเควสสำเร็จ รางวัลจาก event) ทุกจุดที่ดึงสกุลเงินออกจากเกมเรียกว่า sink (การ pull gacha การอัปเกรดอุปกรณ์ การซื้อของตกแต่ง) นี่คือปัญหาเดียวกับที่ธนาคารกลางของประเทศต้องกังวล ถ้า faucet ผลิตออกมาเร็วกว่า sink ดูดเข้าไปตลอด ผู้เล่นจะสะสมสกุลเงินไว้มากกว่าที่เกมให้เหตุผลให้ใช้จ่าย ราคาต่าง ๆ จะรู้สึกไร้ความหมาย และโดยเฉพาะ premium currency จะเสียความรู้สึก "พิเศษ" ไป ซึ่งเป็นรูปแบบความล้มเหลวเดียวกับเงินเฟ้อในโลกจริง ถ้า sink ดูดเร็วกว่า faucet ผลิต ผู้เล่นจะหมดเงิน รู้สึกถูกลงโทษจากการเล่นตามปกติ แล้วก็เลิกเล่นไปในที่สุด

FAUCETS SINKS (currency enters economy) (currency leaves economy) daily login reward -----> quest completion -----> [ Player's ] -----> gacha pulls event rewards -----> [ Wallet ] -----> gear upgrades achievement bonus -----> [ ] -----> cosmetic shop total faucet output per week should stay roughly matched to total sink capacity per week -- tracked continuously, not guessed once

ทีม live-ops จะติดตามเรื่องนี้ด้วยตัวเลขจริง ไม่ใช่การเดา คือรวมผลผลิตที่คาดไว้ต่อสัปดาห์ของทุก faucet ทั่วทั้งฐานผู้เล่น รวมการดูดที่คาดไว้ของทุก sink แล้วคอยดูส่วนต่างของทั้งสองไปเรื่อย ๆ ตามเวลา


public class EconomyReport
{
    public static void CheckBalance(long totalFaucetOutput, long totalSinkCapacity)
    {
        long difference = totalFaucetOutput - totalSinkCapacity;
        double percentOff = (double)difference / totalSinkCapacity * 100.0;

        if (Math.Abs(percentOff) < 5.0)
        {
            Console.WriteLine($"Balanced: faucets vs sinks off by {percentOff:F1}%");
        }
        else if (percentOff > 0)
        {
            Console.WriteLine($"Warning: faucets exceed sinks by {percentOff:F1}% -- inflation risk");
        }
        else
        {
            Console.WriteLine($"Warning: sinks exceed faucets by {Math.Abs(percentOff):F1}% -- currency drought risk");
        }
    }
}

EconomyReport.CheckBalance(totalFaucetOutput: 1_040_000, totalSinkCapacity: 1_000_000);

ผลลัพธ์ที่คาดหวัง:


Balanced: faucets vs sinks off by 4.0%

สตูดิโอจริง ๆ จะรันการตรวจสอบแบบนี้ต่อเนื่องกับ telemetry ที่เก็บมาสด ๆ (หัวข้อ 11) ไม่ใช่ทำแค่ครั้งเดียว event ใหม่ที่แจกรางวัล soft-currency ใจกว้างผิดปกติ หรืออุปกรณ์ tier ใหม่ที่จู่ ๆ อัปเกรดถูกลงมาก สามารถทำให้สมดุลเสียได้ภายในไม่กี่วัน และทีม live-ops ก็อยากจะจับสัญญาณนั้นได้จาก dashboard มากกว่าจะรู้จากผู้เล่นบ่นในโซเชียลมีเดียหลังจากนั้นเป็นสัปดาห์

10. Transaction แบบ Server-Authoritative และ Receipt

กฎ server-authoritative แบบเดียวกับในหัวข้อ 5 ใช้ได้กับการเปลี่ยนแปลงสกุลเงินทุกอย่าง ไม่ใช่แค่การสุ่ม gacha client ห้ามตั้งค่าสกุลเงินรวมของตัวเองโดยตรงเด็ดขาด มันแค่ส่ง request ไป เซิร์ฟเวอร์เป็นคนตัดสินว่าจะอนุมัติหรือไม่ ทำการเปลี่ยนแปลง แล้วบอกสถานะใหม่กลับไปให้ client

การซื้อด้วยเงินจริงมีขั้นตอนที่จำเป็นเพิ่มมาอีกหนึ่งขั้น คือ receipt validation เมื่อผู้เล่นซื้อ premium currency app store (Apple App Store, Google Play) คือคนที่เก็บเงินจากบัตรของผู้เล่นจริง ๆ แล้วมันจะส่ง receipt (โทเคนที่เซ็นรับรองแล้วว่าซื้อจริง) ให้กับแอป แต่ receipt นั้นต้องเดินทางย้อนกลับผ่านเซิร์ฟเวอร์ของเกมเองก่อน แล้วถูกตรวจสอบยืนยันกับเซิร์ฟเวอร์ของ app store ก่อนที่จะให้สกุลเงินใด ๆ ทั้งนี้เพื่อไม่ให้ client ที่ถูกแก้ไขสามารถปลอม receipt ขึ้นมาแล้วให้สกุลเงินตัวเองได้ฟรี ๆ

player taps "Buy 1000 Crystals for $9.99" | v device's OS purchase dialog charges the player's payment method | v app store returns a signed RECEIPT to the client | v client sends the receipt to the GAME SERVER (never trusts it locally) | v game server sends the receipt to APPLE/GOOGLE's servers to verify: - is this receipt genuine? - has this exact receipt already been redeemed before? | v valid AND not already redeemed? | | yes no | | v v grant 1000 Crystals, reject -- log the attempt, record the receipt ID grant nothing as "already redeemed"

การบันทึก receipt ID ทุกใบทันทีที่ถูกใช้แลกแล้ว คือสิ่งที่ทำให้การทำงานนี้เป็น idempotent (รันมากกว่าหนึ่งครั้งด้วย input เดิม ให้ผลลัพธ์เหมือนกับรันครั้งเดียว) transaction ledger เล็ก ๆ (บันทึกของ receipt ที่ถูกใช้แลกไปแล้วทุกใบ) จะทำให้เห็นภาพชัดขึ้น:


public class ReceiptLedger
{
    private HashSet<string> redeemedReceiptIds = new HashSet<string>();

    public bool TryRedeem(string receiptId, long crystalAmount, PlayerWallet wallet)
    {
        if (redeemedReceiptIds.Contains(receiptId))
        {
            Console.WriteLine($"Rejected: receipt {receiptId} already redeemed");
            return false;
        }

        wallet.GrantPremiumCurrency(crystalAmount, "purchase " + receiptId);
        redeemedReceiptIds.Add(receiptId);
        return true;
    }
}

var ledger = new ReceiptLedger();
var wallet2 = new PlayerWallet();
ledger.TryRedeem("receipt_A1", 1000, wallet2);
ledger.TryRedeem("receipt_A1", 1000, wallet2); // the same receipt sent again

ผลลัพธ์ที่คาดหวัง:


Granted 1000 premium currency: purchase receipt_A1
Rejected: receipt receipt_A1 already redeemed

ปัญหาเครือข่ายสะดุดที่ทำให้ client ส่ง receipt เดิมซ้ำสองครั้ง หรือ client ที่ประสงค์ร้ายจงใจส่ง receipt เก่ากลับมาซ้ำ ทั้งสองกรณีจะถูกปฏิเสธในความพยายามครั้งที่สอง เพราะเซิร์ฟเวอร์มี receipt ID ตัวนั้นทำเครื่องหมายว่าใช้ไปแล้ว นี่คือแนวคิดเดียวกับที่บท persistence ก่อนหน้านี้ใช้กับ write-ahead log ของระบบ save คือบันทึกถาวรแบบ append-only ที่ทำให้ทีม support ตอบคำถามว่า "สกุลเงินของผู้เล่นคนนี้หายไปไหน" ได้ในอีกหลายเดือนต่อมา แค่อ่านบันทึกแทนที่จะต้องเดา

Common mistake การให้สกุลเงินทันทีที่ client รายงานว่า "ซื้อสำเร็จ" ก่อนที่เซิร์ฟเวอร์จะยืนยัน receipt กับ app store นี่คือช่องโหว่ gacha ที่พบบ่อยที่สุดในโลกจริง client ที่ถูกแก้ไข หรือเครื่องมือดักจับเครือข่ายที่แก้ไข traffic ของแอป สามารถส่งข้อความปลอม "ซื้อสำเร็จ นี่คือ receipt" ที่ไม่เคยถูกเก็บเงินจาก app store จริง ๆ เลย การยืนยันกับเซิร์ฟเวอร์ของ app store เอง ไม่ใช่แค่เชื่อสิ่งที่ client อ้าง คือสิ่งที่ต้องทำ ไม่ใช่ทางเลือก

11. A/B Testing, Analytics และ Telemetry

Telemetry (สตรีมของ event ที่ client และเซิร์ฟเวอร์บันทึกอัตโนมัติเมื่อผู้เล่นทำอะไรบางอย่าง เช่น เปิดร้านค้า เริ่ม pull จบด่าน) คือวิธีที่ทีม live-ops รู้ว่า event หนึ่ง ๆ ได้ผลจริงหรือเปล่า แทนที่จะเดาเอา event ทั่วไปจะถูกบันทึกเป็นเรคคอร์ดที่มีโครงสร้างเล็ก ๆ:


public class TelemetryEvent
{
    public string EventName;      // "gacha_pull_started", "shop_opened", ...
    public string PlayerId;
    public long TimestampUnix;
    public Dictionary<string, object> Params;
}

var evt = new TelemetryEvent {
    EventName = "gacha_pull_started",
    PlayerId = "player_4471",
    TimestampUnix = 1_737_020_000,
    Params = new Dictionary<string, object> {
        { "banner_id", "characterA_banner" },
        { "pull_count", 10 },
        { "currency_spent", 1600 },
    }
};
Console.WriteLine($"{evt.EventName} by {evt.PlayerId}: banner={evt.Params["banner_id"]}, pulls={evt.Params["pull_count"]}");

ผลลัพธ์ที่คาดหวัง:


gacha_pull_started by player_4471: banner=characterA_banner, pulls=10

เมื่อคูณด้วยผู้เล่นหลายล้านคน event เหล่านี้ก็ตอบคำถามอย่างเช่น มีผู้เล่นกี่คนที่เปิดร้านค้าแล้วไม่ซื้ออะไรเลย (funnel คืออัตราการหลุดออกระหว่างขั้นตอนหนึ่งไปอีกขั้นตอนหนึ่ง)? มีผู้เล่นสัดส่วนเท่าไหร่ที่ยังเล่นอยู่หลังติดตั้งไปแล้วหนึ่งวัน เจ็ดวัน สามสิบวัน (D1/D7/D30 retention)? ผู้เล่นที่จ่ายเงินโดยเฉลี่ยใช้จ่ายเท่าไหร่ (ARPPU คือรายได้เฉลี่ยต่อผู้ใช้ที่จ่ายเงิน)?

A/B testing ใช้กลไก feature-flag แบบเดียวกับหัวข้อ 4 เพื่อตอบคำถามที่คมกว่านั้น คือการเปลี่ยนสิ่งใดสิ่งหนึ่งโดยเฉพาะ ช่วยได้จริงไหม? แทนที่จะมี flag เดียวเปิดหรือปิดให้ทุกคนเหมือนกันหมด ผู้เล่นจะถูกแบ่งเป็นกลุ่มด้วยการ hash player ID ให้ตกอยู่ใน bucket ที่คงที่ ทำให้ผู้เล่นคนเดิมตกอยู่กลุ่มเดิมเสมอในทุกเซสชัน วิธีนี้ต้องใช้ hash ที่ให้ผลเหมือนเดิมทุกครั้งที่เกมรัน ไม่ใช่ string.GetHashCode() ซึ่ง .NET จงใจสุ่มให้ต่างกันทุกครั้งที่โปรเซสเริ่มใหม่ จึงห้ามเอามาใช้กับอะไรก็ตามที่ต้องคงที่ข้ามเซสชันเด็ดขาด:


public static int StableHash(string input)
{
    int hash = 0;
    foreach (char c in input)
    {
        hash = (hash * 31 + c) % 1000000;
    }
    return Math.Abs(hash);
}

public static string AssignVariant(string playerId, int percentInVariantB)
{
    int bucket = StableHash(playerId) % 100; // 0..99, stable across runs
    return bucket < percentInVariantB ? "B" : "A";
}

string[] players = { "alice", "bob", "carol", "dave" };
foreach (string p in players)
{
    Console.WriteLine($"{p} -> variant {AssignVariant(p, percentInVariantB: 50)}");
}

ผลลัพธ์ที่คาดหวัง:


alice -> variant B
bob -> variant B
carol -> variant B
dave -> variant A

ลองคำนวณ hash ด้วยมือดูก็ยืนยันได้ alice ตกอยู่ bucket 40, bob ตก bucket 17, carol ตก bucket 9 ทั้งหมดต่ำกว่าเกณฑ์ 50 จึงได้ "B" ทั้งหมด ในขณะที่ dave ตก bucket 76 จึงได้ "A" ด้วยตัวอย่างผู้เล่นแค่สี่คน การแบ่งนี้ดูไม่ใกล้เคียง 50/50 เลย ซึ่งจะเกิดขึ้นก็ต่อเมื่อแบ่ง bucket ผู้เล่นเป็นพัน ๆ คนเท่านั้น เหตุผลเดียวกันเป๊ะ ๆ กับที่การคำนวณโยนเหรียญในหัวข้อ 8 ต้องใช้ pull จำนวนมากก่อนที่อัตราจริงจะปรากฏออกมาอย่างน่าเชื่อถือ Variant A อาจเห็นภาพอาร์ตของ banner แบบเดิม Variant B เห็นหน้าจอ banner ที่ออกแบบใหม่ ทั้งสองกลุ่มเล่นเกมตามปกติ telemetry บันทึกว่าแต่ละกลุ่ม pull และใช้จ่ายไปเท่าไหร่จริง ๆ พอมีผู้เล่นผ่านทั้งสอง variant มากพอแล้ว ทีม live-ops ก็เปรียบเทียบตัวเลขของสองกลุ่มเพื่อตัดสินใจว่าเวอร์ชันไหนจะปล่อยให้ทุกคน

Tip การแบ่ง bucket ด้วย hash ที่คงที่ของ player ID (แทนที่จะสุ่มใหม่ทุกเซสชัน) คือสิ่งที่ทำให้ผู้เล่นอยู่ variant เดิมทุกครั้งที่ล็อกอิน ถ้าไม่มีความคงที่แบบนี้ ผู้เล่นอาจเห็น UI ต่างกันไปทุกเซสชัน ซึ่งทำลายการทดลอง (พฤติกรรมของพวกเขากลายเป็นปฏิกิริยาต่อประสบการณ์ที่ไม่คงที่) พอ ๆ กับที่มันทำลายความเชื่อใจ

12. จริยธรรมและกฎระเบียบ: การเปิดเผยโอกาส ขีดจำกัดการใช้จ่าย และกฎหมายเกี่ยวกับ Loot Box

ในเชิงกลไก ระบบ gacha ก็คือสิ่งเดียวกับ loot box หรือสล็อตแมชชีน คือจ่ายสิ่งที่มีมูลค่าเพื่อแลกกับรางวัลแบบสุ่ม หน่วยงานกำกับดูแลในหลายประเทศมากขึ้นเรื่อย ๆ มองมันแบบนั้น และกฎด้านล่างนี้ไม่ใช่ตัวเลือกเสริม แต่เป็นข้อบังคับทางกฎหมายในหลายตลาดที่เกมอย่าง Genshin Impact วางขายอยู่จริง ๆ

ทั้งหมดนี้ไม่ได้บอกว่ากลไกนี้ไม่ซื่อสัตย์โดยธรรมชาติ drop rate ที่เปิดเผยและเพดาน hard pity ในเชิงกลไกแล้วถือเป็นข้อตกลงที่เป็นธรรม เพราะมันถูกเปิดเผยและถูกจำกัดขอบเขตไว้ ผู้เล่นที่เลือกจะ pull สามารถตรวจดูล่วงหน้าได้ว่ากรณีที่แย่ที่สุดที่อาจเจอคืออะไร และโอกาสที่แน่นอนที่ตัวเองกำลังยอมรับคืออะไร รูปแบบความล้มเหลวทางจริยธรรมที่ทีม live-ops ควรหลีกเลี่ยงคือด้านตรงข้าม คือการซ่อนโอกาสที่แท้จริง การออกแบบภาพ "เกือบได้" ที่ตั้งใจทำให้การพลาดรู้สึกเหมือนเกือบชนะ หรือการใช้ตัวนับถอยหลังและความขาดแคลนที่สร้างขึ้นมาเพื่อกดดันให้ตัดสินใจซื้อเร็วกว่าที่ผู้เล่นจะตัดสินใจเองตามปกติ สตูดิโอที่เปิดเผย rate อย่างตรงไปตรงมา รักษากลไก pity ให้มีขอบเขตจริง ๆ และเคารพการป้องกันด้านการใช้จ่ายและอายุตามที่กฎหมายท้องถิ่นกำหนด ไม่ได้ทำอะไรต่างไปจากหัวข้อ 5 ถึง 9 ของบทนี้ในเชิงกลไกเลย เพียงแต่ทำมันอย่างโปร่งใส แทนที่จะซ่อนมันไว้

Common mistake การมอง "ตรงนี้มันถูกกฎหมายทางเทคนิค" ว่าเป็นเกณฑ์เดียวที่ต้องผ่าน กฎระเบียบมักเป็นพื้นล่างสุด ไม่ใช่เป้าหมาย สตูดิโอที่ทำแค่ขั้นต่ำตามกฎหมาย ในประเทศที่กฎหลวมที่สุด กำลังเดิมพันความเชื่อใจของฐานผู้เล่นเหมือนกับสตูดิโอที่ข้าม input validation กำลังเดิมพันว่าจะไม่มีแฮกเกอร์โผล่มาเลย ทั้งสองอย่างสุดท้ายก็จะถูกทดสอบจนได้

13. อภิธานศัพท์ (Glossary)

14. แบบฝึกหัด

Exercise 1 — เพิ่มไอเทมใน Weighted Pool pool จากหัวข้อ 6 มีสามไอเทม weight เป็น 940, 51, 9 (รวม 1000) ให้เพิ่มไอเทมที่สี่ชื่อ "exclusive_skin" rarity "5-star-exclusive" weight เท่ากับ 1 โดยไม่เปลี่ยน weight ของสามไอเทมเดิมเลย เขียนช่วง cumulative ใหม่ของทั้งสี่ไอเทม แล้วไล่ PickByRoll ด้วยมือสำหรับ roll 995 และ 1000 ว่าแต่ละอันคืนไอเทมอะไร
Show answer

การเพิ่มไอเทมที่สี่ทำให้ total weight เพิ่มเป็น 1001 ช่วงของแต่ละไอเทมเริ่มตรงจุดที่ cursor ของไอเทมก่อนหน้าหยุดไว้ common_sword ครอบครอง [0, 940), rare_bow ครอบครอง [940, 991), legendary_gem ครอบครอง [991, 1000) และ exclusive_skin ตัวใหม่ครอบครอง [1000, 1001) ซึ่งเป็นช่วงที่เล็กที่สุดเท่าที่จะเป็นไปได้ ตรงกับ weight ของมันที่เป็น 1


pool.Items.Add(new GachaItem { Id="exclusive_skin", Rarity="5-star-exclusive", Weight=1 });

int[] scriptedRolls = { 995, 1000 };
foreach (int roll in scriptedRolls)
{
    GachaItem result = pool.PickByRoll(roll);
    Console.WriteLine($"roll={roll} -> {result.Id} ({result.Rarity})");
}

roll=995 -> legendary_gem (5-star)
roll=1000 -> exclusive_skin (5-star-exclusive)

995 เกิน 991 มาแล้วแต่ยังไม่ถึง 1000 จึงตกอยู่ในช่วงของ legendary_gem ส่วน 1000 ไม่ผ่านเงื่อนไขของ legendary_gem (1000 < 1000 เป็นเท็จ) แต่ผ่านเงื่อนไขของ exclusive_skin (1000 < 1001 เป็นจริง) จึงตกอยู่ในช่วงเล็ก ๆ ใหม่นี้ ตรงตามที่ weight ของมัน 1 จาก 1001 ทั้งหมดบอกไว้ว่ามันควรจะแทบไม่เกิดขึ้นเลย

Exercise 2 — ไล่ Trace Pity Counter ผู้เล่นคนหนึ่งเริ่มเซสชันด้วย PullsSinceLastFiveStar = 72 (ใช้ค่า default ของ PityConfig เดียวกับหัวข้อ 7 คือ base rate 0.9%, soft pity เริ่มที่ pull 74, soft pity step 6% ต่อ pull, hard pity ที่ pull 90) พวกเขา pull ติดกันสี่ครั้ง โดยค่า nextRoll ที่กำหนดไว้คือ 0.95, 0.95, 0.03, 0.95 ใช้ PityGacha.RollOnePull ไล่ด้วยมือว่าแต่ละ pull ทั้งสี่ครั้งคืนอะไร และ pity counter หลังจากแต่ละครั้งเป็นเท่าไหร่
Show answer

var pity = new PityState { PullsSinceLastFiveStar = 72 };
var cfg = new PityConfig();

double[] scriptedRolls = { 0.95, 0.95, 0.03, 0.95 };
int i = 0;
Func<double> nextRoll = () => scriptedRolls[i++];

for (int n = 0; n < 4; n++)
{
    Console.WriteLine(PityGacha.RollOnePull(pity, cfg, nextRoll));
}

no 5-star (pull #73, rate was 0.9%)
no 5-star (pull #74, rate was 0.9%)
5-star! (pull #75, rate was 6.9%)
no 5-star (pull #1, rate was 0.9%)

pull ที่ 1 คือ pull number 73 (72 + 1) ยังต่ำกว่าเกณฑ์ soft pity ที่ 74 อยู่ rate จึงยังคงที่ 0.9% เฉย ๆ และ 0.95 พลาดง่าย ๆ pull ที่ 2 คือ pull number 74 ตรงเกณฑ์ soft pity พอดี แต่ stepsIntoSoftPity เป็น 0 ตรงจุดนี้ rate จึงยังเป็น base 0.9% เป๊ะ ๆ และพลาดอีกครั้ง pull ที่ 3 คือ pull number 75 เข้าสู่ soft pity ไปหนึ่งก้าว ทำให้ rate ดันขึ้นเป็น 0.9% + 1 * 6% = 6.9% คราวนี้ roll ต่ำที่ 0.03 ต่ำกว่า rate นั้น จึงชนะ และ counter รีเซ็ตกลับเป็น 0 pull ที่ 4 เริ่มนับใหม่ทั้งหมดที่ pull number 1 กลับไปที่ base rate 0.9% ซึ่ง 0.95 ก็พลาดอีกครั้ง

Exercise 3 — หา Check ที่หายไป วิศวกรเซิร์ฟเวอร์ระดับ junior คนหนึ่งเขียน endpoint จัดการการซื้อ (pseudocode) นี้ไว้สำหรับให้ premium currency หลังการซื้อในแอป มันทำงานได้ปกติดีตอนทดสอบทั่วไป แต่รายงาน QA บอกว่า tester คนหนึ่งสามารถได้รางวัล 1000 Crystals แบบเดิมซ้ำได้ ด้วยการส่ง request เดิมเป๊ะ ๆ ซ้ำสามครั้ง อ่านโค้ดนี้ อธิบายว่า check อะไรหายไป และทำไมมันถึงเปิดช่องให้เกิด exploit นี้ได้ แล้วเขียนเวอร์ชันที่แก้ไขแล้ว

function OnPurchaseReceiptReceived(playerId, receipt):
    isValid = VerifyReceiptWithAppStore(receipt)
    if not isValid:
        return Error("invalid receipt")

    GrantPremiumCurrency(playerId, receipt.crystalAmount)
    return Success("granted " + receipt.crystalAmount + " crystals")
Show answer

ฟังก์ชันนี้ขาด check "ใช้แลกไปแล้วหรือยัง" ที่ ReceiptLedger ในหัวข้อ 10 มีอยู่แล้ว VerifyReceiptWithAppStore ยืนยันได้แค่ว่า receipt เป็นโทเคนแท้ที่เซ็นรับรองแล้วซึ่ง app store ออกให้จริง แต่มันไม่รู้ และไม่มีทางรู้ได้ ว่าเซิร์ฟเวอร์ของเกมจ่ายรางวัลให้ receipt ใบเดียวกันนี้ไปแล้วครั้งหนึ่งหรือยัง receipt ที่แท้จริงก็ยังคงแท้อยู่ดีไม่ว่าจะถูกตรวจสอบกี่ครั้งก็ตาม ดังนั้นการส่ง receipt ที่ valid ตัวเดิมซ้ำสามครั้งจะผ่าน isValid ทั้งสามครั้ง และ GrantPremiumCurrency ก็จะรันสามครั้งตามไปด้วย


function OnPurchaseReceiptReceived(playerId, receipt):
    isValid = VerifyReceiptWithAppStore(receipt)
    if not isValid:
        return Error("invalid receipt")

    if ReceiptAlreadyRedeemed(receipt.id):
        return Error("receipt already redeemed")

    GrantPremiumCurrency(playerId, receipt.crystalAmount)
    MarkReceiptAsRedeemed(receipt.id)
    return Success("granted " + receipt.crystalAmount + " crystals")

ทางแก้คือเพิ่ม check ตัวเดียวกับที่ ReceiptLedger.TryRedeem ทำอยู่แล้วในหัวข้อ 10 คือค้นหา ID เฉพาะของ receipt ในบันทึกถาวรของ receipt ที่ใช้แลกไปแล้ว ก่อนที่จะให้รางวัลใด ๆ แล้วเขียน ID นั้นลงในบันทึกทันทีที่ให้รางวัลเสร็จ คำขอชุดที่สองและสามของ request เดิมตอนนี้จะไม่ผ่าน check ตัวใหม่และไม่ได้รับอะไรเลย ในขณะที่ครั้งแรกสุดยังคงสำเร็จตามปกติ ทำให้ endpoint นี้กลายเป็น idempotent ตรงตามที่หัวข้อ 10 นิยามคำนี้ไว้เป๊ะ ๆ

← กลับไปหน้ารวมบท