หลักการสร้างบอทสำหรับบอร์ดเกม
หลักการทำบอทสำหรับบอร์ดเกม โดยแบ่งเป็น 3 ระดับ สำหรับนำไปใช้กับเกมบนเวปแอพที่สร้างทุกเกม
หลักการสร้างบอท AI สำหรับบอร์ดเกมบนเว็บ
Oct 2, 2026 · @Poom
ภาพรวม
บอทที่ดีสำหรับบอร์ดเกมบนเว็บต้องมี 3 ระดับที่เก่งต่างกันจริงและวัดผลได้ ไม่โกง และตอบภายในเวลาที่ผู้เล่นรอได้ เอกสารนี้สรุปหลักการที่ใช้ได้กับบอร์ดเกมแบบผลัดตาทั่วไป รวมถึงเกมที่มีข้อมูลซ่อน (ไพ่ในมือ กองการ์ด) และความสุ่ม (ทอยเต๋า จั่วการ์ด)
หลักการตั้งต้น 4 ข้อ:
- ไม่โกง: บอทเห็นข้อมูลเท่ากับผู้เล่นหนึ่งคน ส่วนที่มองไม่เห็นต้องเดาเอง
- ถูกกติกาเสมอ: บอทเลือกได้เฉพาะท่าจากรายการ legal actions ของ engine และต้องมีเทสต์ยืนยัน
- ทำซ้ำได้: ส่ง seed เดิมกับสถานะเดิม ต้องได้ท่าเดิม เพื่อ debug และ replay ได้
- ความยากวัดได้: แต่ละระดับมีเกณฑ์อัตราชนะเทียบกับระดับที่ต่ำกว่า และวัดจากการจำลองหลายพันเกม
| ระดับ | ความรู้สึกที่ต้องการ | กลุ่มเป้าหมาย |
|---|---|---|
| ง่าย | เล่นถูกกติกา แต่ตามใจ พลาดให้เห็นบ่อย | เด็ก ผู้เล่นครั้งแรก |
| กลาง | เก็บแต้มระยะสั้นเก่ง หลบความเสียหายชัด ๆ ได้ | ผู้เล่นทั่วไป |
| ยาก | มองไกล เดามือคู่แข่ง กดคนที่นำ เล่นเพื่อชนะ | แฟนบอร์ดเกม |
Engine ที่บอทต้องการ
บอทจะเก่งได้แค่ไหน ขึ้นกับว่าจำลองเกมได้เร็วแค่ไหน จึงต้องออกแบบ engine ให้เป็นโค้ดล้วน ๆ ที่ไม่ผูกกับ UI ตั้งแต่แรก แยกเป็นแพ็กเกจ engine, bots และ sim ที่รันได้ทั้งในเบราว์เซอร์ ใน Web Worker และใน Node
| สิ่งที่ engine ต้องมี | ทำไมบอทต้องใช้ |
|---|---|
GameState เป็น JSON ล้วน (ไม่มี class, ไม่มี reference วน) | clone เร็ว, ส่งข้าม Worker ได้, บันทึก/replay ได้ |
pending บอกว่าตอนนี้ใครต้องตัดสินใจอะไร | ทุกจุดตัดสินใจ (รวมคำถามย่อยกลางเทิร์น) ผ่านบอทตัวเดียว |
getLegalActions(state) | บอทเลือกจากรายการนี้เท่านั้น จึงไม่มีทางเล่นผิดกติกา |
applyAction แบบ pure และแบบ in-place | pure ใช้กับ 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() |
วิธีสุ่มให้สอดคล้อง:
- สร้างสำรับเต็มของแต่ละคนจากกติกา แล้วตัดการ์ดที่ “รู้ตัวตน” แล้วออก (อยู่ในมือเรา หรือเปิดอยู่)
- สับส่วนที่เหลือ แล้วแจกตามลำดับ: การ์ดคว่ำบนกระดาน → มือ (ตามจำนวนที่เห็น) → กองจั่ว
- สับกองสุ่มกลาง (เช่น กองเหตุการณ์) จากใบที่ “ยังเหลือ” ซึ่ง view ควรเปิดเผยเป็นค่ารวม (นับไพ่ได้ แต่ไม่รู้ลำดับ)
- ตั้ง RNG ของ state ใหม่ด้วย
rng.fork()เพื่อไม่ใช้ seed จริงของเกม
ข้อควรระวัง:
- สุ่มแบบเท่ากันทุกใบเป็นจุดเริ่มที่ดีพอ อย่าเพิ่งทำ inference จากพฤติกรรมคู่แข่งจนกว่าจะวัดได้ว่าคุ้ม
- บอทที่ใช้ determinization ซ้ำหลายครั้ง (ระดับยาก) ต้องสุ่มใหม่ทุก iteration ไม่เช่นนั้นจะยึดติดกับการเดาแบบเดียว
- เทสต์ที่ต้องมี: สอง state ที่ข้อมูลลับต่างกันแต่ view เหมือนกัน บอทต้องตัดสินใจเหมือนกันทุกครั้ง (seed เดิม)
บันไดระดับความยาก
แต่ละระดับเพิ่ม “ความคิด” ทีละชั้น และระดับบนใช้ชิ้นส่วนของระดับล่างซ้ำ เช่น บอทยากใช้ค่าของบอทกลางคัดตัวเลือก และทุกระดับ fallback ไปบอทง่ายได้
| ระดับ | อัลกอริทึม | มองไกล | คิดถึงคู่แข่ง | เวลาต่อตา |
|---|---|---|---|---|
| Random | สุ่มจาก legal actions | ไม่มอง | ไม่ | ทันที |
| Easy | สุ่มแบบถ่วงน้ำหนัก + ตัดท่าโง่ | ผลทันที | ไม่ | < 5 ms |
| Medium | Greedy: ลองทุกท่า จำลองถึงจุดนับคะแนนถัดไป | จบรอบ/เฟสนี้ | นิดหน่อย (สมมติว่า pass) | 10–100 ms |
| Hard | Determinized flat Monte-Carlo + UCB1 | 1–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 ×3 | Easy ได้คะแนนสูงกว่า Random ในคู่ส่วนใหญ่ |
| Medium vs Easy (2v2) | Medium ชนะคู่ ≥ 70% |
| Hard vs Medium (2v2) | Hard ชนะคู่ ≥ 65% |
| Hard ×1 vs Medium ×3 | Hard win share ≥ 40% (baseline 25%) |
| เวลาตัดสินใจ Hard | p95 ≤ 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
ผู้เล่นไว้ใจบอทมากขึ้นเมื่อรู้ว่ามันไม่โกง ควรมีหน้า “บอทคิดยังไง” เปิดได้จากหน้าเลือกคู่แข่งและหน้าวิธีเล่น เนื้อหาครบชุดมีดังนี้:
- บอทไม่โกง: สิ่งที่ “ใช้ตามจริง” กับสิ่งที่ “สุ่มเดา”
- แต่ละระดับ: สรุปหนึ่งประโยค, ขั้นตอนเป็นข้อ, สูตรบอทกลาง, ผังขั้นตอนบอทยาก
- ตารางเทียบ: มองไกล, คิดถึงคู่แข่ง, คิดถึงความสุ่ม, ความพลาด
- ควรเลือกระดับไหน และหมายเหตุเรื่อง 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 ซ้ำทุกครั้งที่กติกาเปลี่ยน และบันทึกผลในเอกสารออกแบบ