การทดลอง #007

หลักการสร้างบอทสำหรับบอร์ดเกม

หลักการทำบอทสำหรับบอร์ดเกม โดยแบ่งเป็น 3 ระดับ สำหรับนำไปใช้กับเกมบนเวปแอพที่สร้างทุกเกม

2026ทดลอง
boardgamebot

หลักการสร้างบอท AI สำหรับบอร์ดเกมบนเว็บ

Oct 2, 2026 · @Poom

ภาพรวม

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

หลักการตั้งต้น 4 ข้อ:

  1. ไม่โกง: บอทเห็นข้อมูลเท่ากับผู้เล่นหนึ่งคน ส่วนที่มองไม่เห็นต้องเดาเอง
  2. ถูกกติกาเสมอ: บอทเลือกได้เฉพาะท่าจากรายการ legal actions ของ engine และต้องมีเทสต์ยืนยัน
  3. ทำซ้ำได้: ส่ง seed เดิมกับสถานะเดิม ต้องได้ท่าเดิม เพื่อ debug และ replay ได้
  4. ความยากวัดได้: แต่ละระดับมีเกณฑ์อัตราชนะเทียบกับระดับที่ต่ำกว่า และวัดจากการจำลองหลายพันเกม
ระดับความรู้สึกที่ต้องการกลุ่มเป้าหมาย
ง่ายเล่นถูกกติกา แต่ตามใจ พลาดให้เห็นบ่อยเด็ก ผู้เล่นครั้งแรก
กลางเก็บแต้มระยะสั้นเก่ง หลบความเสียหายชัด ๆ ได้ผู้เล่นทั่วไป
ยากมองไกล เดามือคู่แข่ง กดคนที่นำ เล่นเพื่อชนะแฟนบอร์ดเกม

Engine ที่บอทต้องการ

บอทจะเก่งได้แค่ไหน ขึ้นกับว่าจำลองเกมได้เร็วแค่ไหน จึงต้องออกแบบ engine ให้เป็นโค้ดล้วน ๆ ที่ไม่ผูกกับ UI ตั้งแต่แรก แยกเป็นแพ็กเกจ engine, bots และ sim ที่รันได้ทั้งในเบราว์เซอร์ ใน Web Worker และใน Node

สิ่งที่ engine ต้องมีทำไมบอทต้องใช้
GameState เป็น JSON ล้วน (ไม่มี class, ไม่มี reference วน)clone เร็ว, ส่งข้าม Worker ได้, บันทึก/replay ได้
pending บอกว่าตอนนี้ใครต้องตัดสินใจอะไรทุกจุดตัดสินใจ (รวมคำถามย่อยกลางเทิร์น) ผ่านบอทตัวเดียว
getLegalActions(state)บอทเลือกจากรายการนี้เท่านั้น จึงไม่มีทางเล่นผิดกติกา
applyAction แบบ pure และแบบ in-placepure ใช้กับ UI, in-place ใช้ในการจำลองเพื่อไม่ต้อง copy ทุกตา
advance เดินเฟสอัตโนมัติ พร้อมจุดหยุด (เช่น หยุดที่จบรอบ)rollout ไปถึง horizon ที่กำหนดได้
getPlayerView(state, player)ตัดข้อมูลลับออกก่อนส่งให้บอท เป็นกำแพงกันโกง
previewAction(state, action)บอกผลทันที (แต้ม, ทรัพยากรที่เปลี่ยน) ใช้ทั้ง UI และบอทง่าย
RNG ที่ใส่ seed ได้ + fork()ทำซ้ำได้, แยกสุ่มในการจำลองไม่ให้กระทบเกมจริง
ค่ากติกาอยู่ใน RulesConfig ไม่ฝังในโค้ดบอทอ่านค่าคะแนน/โทษจากที่เดียว เมื่อปรับกติกาบอทไม่ต้องแก้

ห้ามใช้ Math.random() หรือเวลาจริงใน engine และบอท ความสุ่มทุกอย่างต้องมาจาก RNG ที่ส่งเข้าไป ส่วนนาฬิกาให้ฉีดเข้ามา (now) เพื่อให้เทสต์แทนที่เวลาด้วยจำนวนรอบได้

Interface ของบอทควรเล็กที่สุด:

interface Bot {
  level: 'random' | 'easy' | 'medium' | 'hard';
  decide(view: PlayerView, ctx: { rng: Rng; now?: () => number; timeBudgetMs?: number; maxIterations?: number }): Action;
}

Determinization: เดาสิ่งที่มองไม่เห็น

บอททุกระดับเริ่มจากฟังก์ชันเดียวกัน: determinize(view, rng) สร้าง GameState เต็มรูปที่สอดคล้องกับทุกอย่างที่ผู้เล่นคนนั้นรู้ แล้วสุ่มส่วนที่เหลือ บอทจึงใช้ฟังก์ชันของ engine ได้ทุกตัวโดยไม่เคยแตะข้อมูลลับจริง

ใช้ตามจริงสุ่มใหม่ทุกครั้ง
มือของผู้เล่นเองมือคู่แข่ง (จำนวนใบเท่าเดิม)
การ์ด/ชิ้นส่วนที่เปิดอยู่บนกระดานการ์ดที่วางคว่ำ
คะแนน, ทรัพยากร, เฟส, ลำดับเทิร์นลำดับกองจั่วทุกกอง
การ์ดที่เปิด/ทิ้งไปแล้วผลสุ่มในอนาคต (ทอยเต๋า, เหตุการณ์) ผ่าน rng.fork()

วิธีสุ่มให้สอดคล้อง:

  1. สร้างสำรับเต็มของแต่ละคนจากกติกา แล้วตัดการ์ดที่ “รู้ตัวตน” แล้วออก (อยู่ในมือเรา หรือเปิดอยู่)
  2. สับส่วนที่เหลือ แล้วแจกตามลำดับ: การ์ดคว่ำบนกระดาน → มือ (ตามจำนวนที่เห็น) → กองจั่ว
  3. สับกองสุ่มกลาง (เช่น กองเหตุการณ์) จากใบที่ “ยังเหลือ” ซึ่ง view ควรเปิดเผยเป็นค่ารวม (นับไพ่ได้ แต่ไม่รู้ลำดับ)
  4. ตั้ง RNG ของ state ใหม่ด้วย rng.fork() เพื่อไม่ใช้ seed จริงของเกม

ข้อควรระวัง:

  • สุ่มแบบเท่ากันทุกใบเป็นจุดเริ่มที่ดีพอ อย่าเพิ่งทำ inference จากพฤติกรรมคู่แข่งจนกว่าจะวัดได้ว่าคุ้ม
  • บอทที่ใช้ determinization ซ้ำหลายครั้ง (ระดับยาก) ต้องสุ่มใหม่ทุก iteration ไม่เช่นนั้นจะยึดติดกับการเดาแบบเดียว
  • เทสต์ที่ต้องมี: สอง state ที่ข้อมูลลับต่างกันแต่ view เหมือนกัน บอทต้องตัดสินใจเหมือนกันทุกครั้ง (seed เดิม)

บันไดระดับความยาก

แต่ละระดับเพิ่ม “ความคิด” ทีละชั้น และระดับบนใช้ชิ้นส่วนของระดับล่างซ้ำ เช่น บอทยากใช้ค่าของบอทกลางคัดตัวเลือก และทุกระดับ fallback ไปบอทง่ายได้

ระดับอัลกอริทึมมองไกลคิดถึงคู่แข่งเวลาต่อตา
Randomสุ่มจาก legal actionsไม่มองไม่ทันที
Easyสุ่มแบบถ่วงน้ำหนัก + ตัดท่าโง่ผลทันทีไม่< 5 ms
MediumGreedy: ลองทุกท่า จำลองถึงจุดนับคะแนนถัดไปจบรอบ/เฟสนี้นิดหน่อย (สมมติว่า pass)10–100 ms
HardDeterminized flat Monte-Carlo + UCB11–2 รอบหรือจบเกมจำลองให้เล่นจริงงบเวลา 0.5–1 วินาที

Random ไม่ให้ผู้เล่นเลือก ใช้เป็น baseline ตอนวัดผล และเป็น fuzzer หาบั๊ก engine

Easy: สุ่ม แต่มีราวกัน

decide(view):
  s = determinize(view)
  if คำถามย่อยที่มีคำตอบดีชัด: return คำตอบนั้น
  if rng < 0.25: return สุ่ม(legal)                ← พลาดให้เห็นจริง
  good = legal.filter(ผลทันที > 0 และ ไม่ทำให้เกิดหายนะทันที)
  return good ว่าง ? สุ่ม(legal) : สุ่มถ่วงน้ำหนัก(good, w = 1 + แต้ม)
  • ความพลาดต้องมาจาก “มองสั้น” และ “สุ่ม” ไม่ใช่การเล่นทำร้ายตัวเองซ้ำ ๆ ซึ่งทำให้ผู้เล่นรู้สึกว่าบอทพัง
  • เกณฑ์ขั้นต่ำ: Easy ต้องชนะ Random ชัดเจน

Medium: โลภ มองถึงจุดนับคะแนนถัดไป

value(s, a) =
  t = clone(s); apply(t, a)
  ทุกคนเล่นท่า “เฉย ๆ” (pass / ค่าเริ่มต้น) จนถึงจุดนับคะแนนถัดไป
  return Δคะแนนเรา − α·Δคะแนนคู่แข่งที่ได้มากสุด + β·outlook(สถานะของเรา)
decide = argmax value (tie-break ด้วย rng · 1e-6)
  • ค่าเริ่มต้นที่ใช้ได้ดี: α = 0.2, β = 0.5 แล้วจูนด้วยการจำลอง
  • outlook = ค่าคาดหวังของสถานะเราหลังเหตุการณ์สุ่มครั้งถัดไป คำนวณแบบเฉลี่ยทุกผลที่เป็นไปได้ (ไม่สุ่ม) เพราะถูกและเร็ว
  • จุดอ่อนโดยเจตนา: สมมติว่าคู่แข่งไม่ทำอะไร ผู้เล่นจึงเอาชนะได้ด้วยการเล่นตอบโต้
  • ใช้ฟังก์ชันเดียวกันกับทุกจุดตัดสินใจ รวมคำถามย่อย ไม่ต้องเขียนกฎพิเศษ

Hard: จำลองอนาคตหลายร้อยแบบ

decide(view):
  C = legal(determinize(view)); if |C| = 1 return C[0]
  if |C| > K: C = top-K ตาม Medium value             ← K ≈ 24
  for i in 0..maxIter while เวลายังไม่หมด (เช็กทุก 16 รอบ):
    arm = i < |C| ? i : argmax_j  w[j]/n[j] + c·√(ln i / n[j])   ← UCB1, c ≈ 0.4
    t = determinize(view)                              ← สุ่มใหม่ทุกครั้ง
    apply(t, C[arm])
    rollout ทุกคนด้วย ε-greedy quick policy จนถึง horizon หรือจบเกม
    n[arm]++; w[arm] += reward(t)                       ← reward อยู่ในช่วง [0, 1]
  return C ที่ n มากที่สุด (เสมอ → w มากกว่า)
  • Flat คือทำ bandit แค่ที่ราก ไม่สร้าง tree ลึก เพราะเกมที่มีข้อมูลซ่อน tree ลึกจะแตกกิ่งตาม determinization จนไม่คุ้ม ทำ flat ก่อน ถ้าไม่ผ่านเกณฑ์ค่อยขยับไป ISMCTS
  • เลือกท่าที่ ถูกลองบ่อยสุด ไม่ใช่ค่าเฉลี่ยสูงสุด เพราะท่าที่ลองน้อยอาจโชคดีจากการสุ่ม
  • ให้ทั้งงบเวลาและเพดาน iteration: เวลาใช้ในเกมจริง ส่วนจำนวน iteration ใช้ตอนจำลองเพื่อให้ผลซ้ำได้
  • horizon สั้น (จบรอบถัดไป) แล้วประเมินด้วย heuristic มักดีกว่า rollout ยาวจนจบเกม เพราะได้ iteration มากกว่าและ noise น้อยกว่า

ฟังก์ชันประเมินและ rollout

ความเก่งจริงของบอทอยู่ที่ฟังก์ชัน 3 ตัวนี้ ส่วนที่เหลือเป็นโครงที่เหมือนกันทุกเกม

1. evaluate(state, me) ประเมินสถานะที่จุดหยุด

V = (\text{score}_{me} - \max_{opp} \text{score}_{opp}) + w \cdot \text{outlook}_{me} \cdot \min(1, \text{remainingRounds})
  • ใช้ ส่วนต่างกับคนที่นำ ไม่ใช่คะแนนตัวเอง บอทจะ “กดคนนำ” เองโดยไม่ต้องเขียนกฎ
  • outlook คือค่าคาดหวังของสิ่งที่เราครอบครองในรอบถัดไป (เฉลี่ยทุกผลสุ่มที่เป็นไปได้) และต้องเป็น 0 เมื่อเกมจบ
  • w เริ่มที่ 0.5–1.0 แล้วจูน

2. reward(state, me) แปลงผลเป็น [0, 1] สำหรับ UCB1

  • กลางเกม: 0.5 + 0.5 · tanh(V / scale) — scale ≈ คะแนนที่ได้ในหนึ่งรอบทั่วไป
  • จบเกม: 0.8 · ส่วนแบ่งชนะ + 0.2 · margin (ชนะร่วม n คน = 1/n) บอทจะเล่นเพื่อชนะ ไม่ใช่เพื่อแต้มสูงสุด ส่วน margin ช่วยแยกท่าเมื่อชนะ/แพ้แน่นอนแล้ว

3. Quick policy สำหรับ rollout

ใน rollout ต้องตัดสินใจแทน ทุกคน หลายหมื่นครั้งต่อตา จึงต้องเร็วระดับไมโครวินาที:

  • ประเมินท่า โดยไม่ clone state คำนวณ delta จากสูตรตรง ๆ ของแต่ละการ์ด
  • ลดตัวเลือก: การ์ดชนิดเดียวกันนับครั้งเดียว, ท่าที่ผลเหมือนกันรวมเป็นหนึ่ง, ท่าที่พารามิเตอร์เยอะ (เช่น ย้ายของหลายทิศ) ใช้ “แผนสำเร็จรูป” 6–12 แบบ
  • มีท่า “pass” ค่า 0 เสมอ เพื่อเป็นทางออกเมื่อทุกท่าเสีย
  • ε-greedy: 90% เลือกท่าค่าสูงสุด 10% สุ่ม เพื่อให้คู่แข่งในการจำลองไม่เดาได้เกินไป
  • คำถามย่อยใน rollout ใช้คำตอบ default ที่มักถูก ไม่ต้องคิด

องค์ประกอบ heuristic ที่ควรมี (ปรับชื่อตามเกม):

องค์ประกอบน้ำหนักเริ่มต้น
คะแนนที่ได้ทันที1.0
สิ่งที่เราครอบครองดีขึ้น/แย่ลง1.0
ทำให้สิ่งที่คู่แข่งครอบครองแย่ลง0.4
สิ่งที่จะมาถึงเราในรอบหน้า0.35
ความเสี่ยงโดนโทษของสิ่งที่เราเพิ่งลงทุน− ค่าโทษเต็ม

ค่าคะแนนและค่าโทษทุกตัวอ่านจาก RulesConfig เสมอ ห้ามฝังตัวเลขกติกาไว้ในบอท

การรันบนเว็บ

บอทต้องคิดใน Web Worker เสมอ หน้าจอจะไม่ค้างแม้บอทยากใช้เวลาเต็มงบ และต้องมีทางสำรองที่ตอบได้ทันทีเมื่อ Worker พังหรือช้าเกิน

[embedded content: การขอท่าจากบอท · ทางปกติและทาง fallback]

ทางปกติคือแถวบน ส่วนทุกทางที่ผิดปกติไหลลงมาที่บอทง่าย เกมจึงเดินต่อได้เสมอ

  • ส่งเฉพาะ PlayerView เข้า Worker ไม่ส่ง state เต็ม ทั้งกันโกงและลดขนาดข้อความ
  • ใส่ id ทุกคำขอ และทิ้งคำตอบที่มาสาย (เช่น ผู้เล่นกด undo หรือออกจากเกมระหว่างรอ)
  • seed ต่อการตัดสินใจ สร้างจาก seed เกม + ลำดับการตัดสินใจ เกมทั้งเกมจึง replay ได้
  • งบเวลาตามระดับ บอทยากประมาณ 900 ms (ควรปรับได้จาก config ฝั่งเซิร์ฟเวอร์) ระดับอื่น 200 ms ส่วน timeout ฝั่ง UI = งบ × 2 + 1.5 วินาที
  • Fallback เมื่อ timeout, error หรือสร้าง Worker ไม่ได้ → รันบอทง่ายบน main thread เกมไม่ค้างแน่นอน
  • ความรู้สึกผู้เล่น: เวลาคิดขั้นต่ำ (เช่น 0.6 วินาที ปรับได้ในหน้าตั้งค่า) แยกจากงบคำนวณ มีสถานะ “กำลังคิด…” และแสดงท่าของบอทด้วยอนิเมชันเดียวกับคน
  • เครื่องช้า: งบเวลาเป็น soft limit บอทยากจะทำ iteration น้อยลงเอง จึงอ่อนลงแต่ไม่ค้าง

การทดสอบและปรับจูน

บอทที่ “ดูเก่ง” อาจแพ้บอทง่ายกว่าได้เสมอ จึงต้องมี simulation harness ที่ให้บอทเล่นกันเองหลายร้อยถึงหลายพันเกม และตัดสินจากตัวเลขเท่านั้น

Harness

  • CLI รับรายชื่อบอทต่อที่นั่ง, จำนวนเกม, seed และไฟล์ผล JSON
  • สลับที่นั่งทุกเกม เพื่อตัดผลของคนเริ่มก่อน/ตำแหน่งที่ได้เปรียบ
  • บอทยากในการจำลองใช้ maxIterations ไม่ใช้เวลา ผลจะซ้ำได้ทุกเครื่อง
  • กระจายเกมไปหลาย worker และรายงานความคืบหน้า

ตัวชี้วัด

  • Win share: ชนะ = 1, ชนะร่วม n คน = 1/n เทียบกับ baseline 1/จำนวนที่นั่ง
  • ชนะคู่ (pairwise): ในเกมแบบ 2 ต่อ 2 นับทุกคู่ (บอท A, บอท B) ว่าใครคะแนนสูงกว่า
  • เวลาตัดสินใจ p95 ของบอทยาก จาก benchmark ที่แยกต่างหาก

เกณฑ์ผ่านที่แนะนำ (4 ที่นั่ง)

การทดลองเกณฑ์
Easy ×1 vs Random ×3Easy ได้คะแนนสูงกว่า Random ในคู่ส่วนใหญ่
Medium vs Easy (2v2)Medium ชนะคู่ ≥ 70%
Hard vs Medium (2v2)Hard ชนะคู่ ≥ 65%
Hard ×1 vs Medium ×3Hard win share ≥ 40% (baseline 25%)
เวลาตัดสินใจ Hardp95 ≤ 1,000 ms บนเดสก์ท็อป

ตัวเลขเกณฑ์ปรับได้ตามเกม เกมที่ดวงมีผลมากจะทำให้ช่องว่างระหว่างระดับแคบลง ให้ตั้งเกณฑ์ก่อนเขียนบอท และรันซ้ำทุกครั้งที่กติกาเปลี่ยน

เทสต์ที่ต้องมี

  • ทุกระดับคืน legal action ในสถานะสุ่มจำนวนมาก (property test)
  • ไม่เห็นข้อมูลลับ: ข้อมูลลับต่าง view เดิม → ท่าเดิม
  • Seed เดิม → ท่าเดิม
  • สถานการณ์ “ต้องเห็น”: ตั้งสถานะที่มีท่าเดียวที่ถูกชัด Medium/Hard ต้องเลือกท่านั้น
  • Acceptance แบบเล็ก (ประมาณ 200 เกม) ใน CI และแบบเต็ม (≥ 2,000 เกม) เป็น nightly หรือก่อน release

การจูน

  • ปรับทีละค่า แล้ววัดด้วย seed ชุดเดิม หรือ grid search เมื่อมีหลายค่า
  • ค่าเริ่มต้นที่ใช้ได้ดี: top-K 24, UCB c 0.4, ε 0.1, 1,500 iterations, α 0.2, β 0.5
  • หาก Hard ชนะ Medium ไม่ขาด ให้ดู rollout policy ก่อน เพราะ rollout ที่เล่นไม่เหมือนคนทำให้ค่าเฉลี่ยผิดทิศ

อธิบายบอทให้ผู้เล่นและ checklist

ผู้เล่นไว้ใจบอทมากขึ้นเมื่อรู้ว่ามันไม่โกง ควรมีหน้า “บอทคิดยังไง” เปิดได้จากหน้าเลือกคู่แข่งและหน้าวิธีเล่น เนื้อหาครบชุดมีดังนี้:

  1. บอทไม่โกง: สิ่งที่ “ใช้ตามจริง” กับสิ่งที่ “สุ่มเดา”
  2. แต่ละระดับ: สรุปหนึ่งประโยค, ขั้นตอนเป็นข้อ, สูตรบอทกลาง, ผังขั้นตอนบอทยาก
  3. ตารางเทียบ: มองไกล, คิดถึงคู่แข่ง, คิดถึงความสุ่ม, ความพลาด
  4. ควรเลือกระดับไหน และหมายเหตุเรื่อง fallback บนเครื่องช้า

ตัวเลขที่ปรับได้จากระบบ (เช่น งบเวลาบอทยาก) ควรดึงจาก config จริง ส่วนค่าคงที่ในโค้ดบอทให้ใส่หมายเหตุในไฟล์หน้าอธิบายว่าต้องแก้คู่กัน

Checklist สำหรับเกมใหม่

  • Engine มี legal actions, apply in-place, advance พร้อมจุดหยุด, player view, seeded RNG
  • เขียน determinize + เทสต์ไม่เห็นข้อมูลลับ
  • Random bot + harness จำลอง (ใช้ Random หาบั๊ก engine ก่อน)
  • ตั้งเกณฑ์ผ่านก่อนเขียนบอทจริง
  • Easy → วัด → Medium → วัด → Hard → วัด (ทีละระดับ)
  • Worker + งบเวลา + fallback + เวลาคิดขั้นต่ำบน UI
  • หน้าอธิบายบอทสำหรับผู้เล่น
  • รัน acceptance ซ้ำทุกครั้งที่กติกาเปลี่ยน และบันทึกผลในเอกสารออกแบบ