Nếu các bạn hay theo dõi mình qua Fanpage Tôi Đi Code Dạo hoặc Youtube Channel, các bạn sẽ thấy lâu lâu mình có hay làm 1 số minigame trao quà bằng cách đặt sòng bầu cua thông qua kênh chat.

Game này chơi khá là vui, tăng tương tác nhiều vì bà con phải comment, lại rất dễ tham gia, chỉ có đang xem stream là được.
Do vậy, mình chia sẻ cho các bạn biết cách để làm 1 game như thế này. Các bạn có thể dựa vào đó để độ chế ra thành game tương tự nhé!
Kiến trúc hệ thống
Trước khi đi sâu vào code, chúng ta có thể tìm hiểu kiến trúc hệ thống trước, để biết cần code những phần nào, code như thế nào nha!
Hệ thống có 1 kiến trúc vô cùng đơn giản đến mức không thể đơn giản hơn, như 1 trang web thông thường:
- Front-end (VueJS): Hiển thị bàn bầu cua, số lượng đặt, bảng xếp hạng người dùng
- Back-end Facebook (NodeJS): Ban đầu, mình làm cái này chơi trên Facebook. Do đó back-end là 1 webhook, nhận thông báo từ Facebook khi có 1 người chơi comment. Sau đó đọc comment và thông báo cho front-end biết là user đó là ai, đã đặt những gì
- Back-end Youtube (NodeJS): Về sau, mình muốn mang trò này qua Youtube. Bản thân Youtube không có Webhook, nhưng có Livestream API, cho phép mình lấy danh sách các comment trong 1 livestream. Do vậy mình chạy code gọi API này mỗi vài giây để lấy comment và xử lý.
- Socket.io: Để hệ thống hoạt động real-time, back-end và front-end giao tiếp với nhau thông qua WebSocket (Thật ra chỉ có 1 chiều là back-end gửi thông tin về front-end thôi). Mình dùng socket.io luôn cho lẹ!
Xử lý phía back-end
Như đã nói, với Facebook, mình có chạy 1 web app nho nhỏ, làm webhook. (Bạn nào quên webhook là gì có thể xem lại bài Làm Facebook ChatBot nha).
Ta sẽ nhận thông tin từ Facebook qua webhook và xử lý.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
app.post('/webhook', async(req, res) => { | |
const hookObject = req.body; | |
console.log(JSON.stringify(hookObject, null, 2)); | |
await process.processHook(hookObject); | |
res.status(200).send("OK"); | |
}); |
Sau đó, ta tiếp tục lấy avatar của người dùng, lấy comment và gửi cho client qua WebSocket.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
async processHook(hookObject) { | |
for (const entry of hookObject.entry) { | |
for (const change of entry.changes) { | |
this.processEntryChange(change); | |
} | |
} | |
} | |
async processEntryChange(change) { | |
if (change.field !== 'feed') return; | |
// New comment only | |
const changeValue = change.value; | |
if (changeValue.post_id !== this.postId) return; | |
if (changeValue.item !== 'comment' || changeValue.verb !== 'add') return; | |
var { sender_id, sender_name, message } = changeValue; | |
const bets = this.getBetFromComment(message); | |
console.log(bets); | |
if (bets.length === 0) return; | |
const avatar = await api.getAvatar(sender_id); | |
for (const bet of bets) { | |
const playerAndBet = { | |
id: sender_id, | |
name: sender_name, | |
avatar, | |
bet: bet.bet, | |
choice: bet.choice | |
}; | |
console.log(playerAndBet); | |
this.emitter.emit('newBet', playerAndBet); | |
} | |
} |
Ở cuối, các bạn sẽ thấy emitter gửi thông tin tới cho client, event là newBet. Ở client, khi nhận được event là newBet, mình sẽ lấy thông tin đó ra và đặt cược cho người chơi.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import io from 'socket.io-client'; | |
const socket = io('http://localhost:3002'); | |
socket.on('connect', () => { console.log('connected') }); | |
socket.on('newBet', function(newBet) { | |
const { id, name, avatar, bet, choice } = newBet; | |
const player = new Player(id, name, avatar); | |
store.dispatch('placeBet', { player, bet, choice }); | |
}); |
Về cơ chế là như vậy, còn 1 đoạn khá hay ho đó là nhận và lọc input từ người chơi. Giả sử người chơi nói “em muốn đặt 10 gà“, mình dùng regex để lọc số lượng đặt là 10, con đặt là gà như sau.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
getBetFromComment(comment) { | |
const cleanedComment = textParser.charToNumber( | |
textParser.removeUnicode(comment.toLowerCase())); | |
const regex = /(\d+)( |–)?(cop|bau|ga|tom|ca|cua)/; | |
const matches = cleanedComment.match(regex); | |
if (!matches) return []; | |
const bets = matches.map(match => { | |
const execMatch = regex.exec(match); | |
const bet = parseInt(execMatch[1], 10); | |
const choice = choiceToNumberMap[execMatch[3]]; | |
return { bet, choice }; | |
}); | |
return bets; | |
} |
Front-end
Ở front-end thì không có quá nhiều thứ phức tạp cần xử lý. Do đợt đấy mình học VueJS nên thử dùng Vue + Vuex để viết hệ thống cho vui luôn.
Phần mình tưởng là khó nhất – hiển thị bàn bầu cua thì hoá ra lại …khá dễ. Chắc do hình ảnh gốc ban đầu đã chia khá đều rồi, chỉ việc code lại xíu thôi~

This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div> | |
<div class="bc-table"> | |
<!– Ảnh bầu cua –> | |
<img src="./../assets/baucua.jpg" alt class="image bc-image" /> | |
<div class="bc-overlay"> | |
<!– 6 cells tương ứng với 6 con –> | |
<div class="boards" :key="key" v-for="(cell, key) in board"> | |
<transition-group | |
tag="div" | |
enter-active-class="animated bounceInDown" | |
leave-active-class="animated fadeOutDown" | |
> | |
<!– Mỗi hình tròn là 1 token của user đã đặt –> | |
<token v-for="(token, userId) in cell" :key="userId" v-bind="token"></token> | |
</transition-group> | |
</div> | |
</div> | |
</div> | |
</div> |
Ngoài ra, do bản chất game có khá nhiều trạng thái, nhiều state nên cần xử lý, lưu trữ nhiều. May mắn là Vuex giúp mình làm chuyện này khá tốt, viết code bằng tay để quản lý state chắc phê lòi luôn!
State của hệ thống cũng khá đơn giản. Mình lưu danh sách người chơi và điểm để làm bảng xếp hạng, số người đã đặt cược, trạng thái của 3 con xí ngầu
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const store = new Vuex.Store({ | |
state: { | |
players: {}, | |
status: WAITING_FOR_BET, | |
dices: [1, 2, 3], | |
board: { | |
1: {}, | |
2: {}, | |
3: {}, | |
4: {}, | |
5: {}, | |
6: {} | |
} | |
} | |
}) |
Sau khi lắc xí ngầu xong, mình tổng kết số điểm, upload lên Firebase để mọi người tham gia có thể vào tra điểm.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
finishGame({ commit, state }) { | |
commit('removeLosers'); | |
// Đếm những quân xí ngầu thắng | |
var diceDic = countBy(state.dices, dice => dice); | |
for (const key in diceDic) { | |
const multiplier = diceDic[key] + 1; | |
const winners = Object.values(state.board[key]); | |
// Cộng điềm cho những người thắng | |
for (const winner of winners) { | |
commit('updatePlayerPoint', { playerId: winner.id, changedValue: (winner.bet * multiplier) }); | |
} | |
} | |
commit('changeStatus', FINISHED); | |
// Upload danh sách người chơi lên Firebase đến tra điểm | |
const syncPlayer = Object.values(state.players) | |
.filter(p => p.id && p.name && p.avatar) | |
.sort((p1, p2) => p2.point – p1.point); | |
firebase.set(syncPlayer); | |
} |

Để làm trang tra điểm này, các bạn muốn dùng gì cũng được. Mình lười nên viết Angular 1, bỏ thành 1 file HTML thuần rồi upload lên Github là xong. Đỡ phải lo build lung tung mệt.
Tạm kết
Đấy, hệ thống trông hay hay, lạ lạ nhưng viết không quá phức tạp như bạn tưởng đâu. Bản thân mình viết tầm 3 ngày nên các bạn viết chắc cỡ 2 ngày là xong ấy mà.
Nếu còn thắc mắc, các bạn có thể xem source code của mình tại đây: github.com/conanak99/baucua. Các bạn có thể Fork hay làm trò gì tuỳ thích, hoặc để lại 1 star cho mình nha.
Nếu có hứng thú về chủ đề thiết kế/code ra 1 hệ thống chuyên sâu thế này thì các bạn cứ để lại comment nha. Nếu nhiều bạn quan tâm mình sẽ làm nhiều hơn ahihi!
Bonus: Clip có khuôn mặt đập trai + demo của mình
Mình rất thích những series có tính chuyên môn và ứng dụng cao thế này, mong bạn tiếp tục phát huy trong thời gian tới nhé!
LikeLike
Để nhận cái event bắn tới webhook khi user comment cần App review đúng ko bạn? Mình subscibe ở phần web hook setting mà backend vẫn ko nhận đc request gì
LikeLike
Cho mình hỏi bạn dùng webhook nào của fb để get được comment vậy? hình như không phải Messenger webhook thì phải. tks bạn
LikeLike