Dự án cá nhân · Chơi được ngay trên web
Lặn Biển Săn Cá — game 3D nhiều người chơi trong trình duyệt
Server Go giữ toàn bộ luật chơi ở nhịp 20Hz, client Babylon.js, một schema protobuf sinh code cho cả hai phía — và voice chat đi qua chính kết nối WebSocket đó thay vì WebRTC.
- Vai trò
- Tác giả duy nhất — server, client, giao thức, hạ tầng
- Thời gian
- 2026
- Công nghệ
- GoWebSocketTypeScriptBabylon.jsVitePostgreSQLPWADocker
20Hz
nhịp mô phỏng do server quyết định
100
người chơi mỗi phòng, tick dưới 50ms
~270KB
gzip cho phần 3D, sảnh chỉ 9KB
64kbps
voice qua WebSocket, không WebRTC
1
schema protobuf sinh cả Go lẫn TS
90m
bán kính chỉ gửi thứ nhìn thấy
133
loài sinh vật chia 5 bậc hiếm
1
VPS chạy toàn bộ, HTTPS tự động
Bài toán
Tôi muốn tự kiểm chứng một câu hỏi: một game nhiều người chơi thời gian thực, 3D, có thể chạy thẳng trong trình duyệt điện thoại — không cài đặt, không store, không WebRTC hay TURN — và ở được trên một VPS nhỏ hay không.
Hai ràng buộc kéo ngược nhau. Server phải giữ quyền quyết định để mọi người trong phòng thấy cùng một thế giới và không ai sửa được kết quả từ phía mình; nhưng cùng lúc đó, mọi thứ phải mượt trên 4G và trên điện thoại tầm trung, nơi băng thông lẫn GPU đều không dư dả.
Và một bài toán không thuộc về kỹ thuật: game nhiều người vào lúc chưa có ai là một mặt biển trống. Người mới mở lên, không thấy ai, rồi đóng tab — trước khi kịp biết game có gì.
Cách giải
- 01
Server quyết định, client chỉ gửi ý định
Một phòng là một vòng lặp Go chạy 20 lần mỗi giây: cá, oxy, va chạm, phát lao trúng hay trượt đều tính ở server. Client gửi ý định — bơi hướng này, bắn lúc này — chứ không gửi kết quả. Điều đó biến hiệu năng thành một con số phải giữ: benchmark một phòng đầy 100 người rải khắp bản đồ phải chạy dưới 50ms mỗi tick, kể cả phần mã hoá và gửi cho từng người, nếu không thì 20Hz không giữ được.
- 02
Chỉ gửi những gì người chơi thật sự nhìn thấy
Bản đồ 1km² chia thành các chunk 128m; mỗi lượt gửi, server dựng một chỉ mục theo chunk rồi chỉ gói phần nằm trong bán kính 90m, vẽ tối đa 16 người gần nhất. Client cũng chỉ tải chunk quanh camera, còn server chỉ mô phỏng cá ở chunk có người. Phòng không có ai đang xem thì nhịp mô phỏng thưa lại — nhờ vậy các phòng luôn mở sẵn gần như không tốn CPU.
- 03
Một schema cho hai ngôn ngữ
Giao thức là Protocol Buffers nhị phân đi trên WebSocket, sinh code cho Go và TypeScript từ cùng một file .proto. Không có bước tuần tự hoá nào viết tay hai lần, nên wire format không thể lệch giữa server và client — thêm một trường là thêm ở một chỗ, và cả hai phía cùng biết về nó khi build.
- 04
Thế giới sinh từ seed, cài hai lần, khoá bằng golden test
Địa hình, đá và xác tàu sinh ra từ một worldSeed, và thuật toán tồn tại hai bản: Go để mô phỏng, TypeScript để dựng hình. Hai bản lệch nhau một chút thôi là người chơi bơi xuyên đá trên máy này nhưng bị chặn ở máy kia. Nên đầu ra được đóng băng thành một file golden dùng chung: sửa thuật toán thì test Go phải khớp lại với file đó, và file đó chính là hợp đồng giữa hai ngôn ngữ.
- 05
Voice chat không cần WebRTC
Micro đi qua AudioWorklet, hạ về 16kHz, nén IMA ADPCM 4-bit còn khoảng 64kbps rồi gửi trên chính kết nối WebSocket đang có — không TURN, không cổng mới, không phụ thuộc dịch vụ ngoài. Server chỉ giới hạn kích thước, giới hạn tốc độ và chuyển tiếp cho cả phòng. Khi hàng đợi của một người nghe bị nghẽn, khung voice bị bỏ chứ snapshot thì không, và kết nối cũng không bị ngắt: nghe cà giật vẫn tốt hơn là văng khỏi phòng.
- 06
Điện thoại là thiết bị chính, không phải trường hợp phụ
Sảnh nặng khoảng 9KB và hiện gần như tức thì, trong khi phần 3D (Babylon.js, ~270KB gzip) tải song song lúc người chơi còn đang chọn phòng. Mức đồ hoạ tự chọn theo thiết bị và tự hạ khi FPS tụt dưới 26, mức đã hạ được nhớ lại cho lần sau. Đồ trang trí mỗi chunk gộp lại còn một hai mesh màu theo đỉnh, joystick và các nút cảm ứng là giao diện mặc định khi xoay ngang, và game cài được ra màn hình chính như một PWA.
- 07
Bot để mặt biển không bao giờ trống
Server luôn giữ sẵn ba phòng công khai và lấp bot cho đủ số người mà phòng đó tự chọn trong khoảng 6–20, đổi lại sau mỗi 12 phút để danh sách phòng có chỗ đông chỗ vắng như thật. Bot săn cá, nổi lên thở, về tàu khi túi đầy và cùng đánh boss; người thật vào thì bot lặng lẽ rút. Bot không có tài khoản và không ghi gì vào database — chúng giải bài toán khởi động nguội mà không làm bẩn dữ liệu hay bảng xếp hạng.
- 08
Vận hành nó như một sản phẩm, không phải một bản demo
Trang quản trị có số liệu người chơi theo ngày, tỉ lệ quay lại D1/D7, phễu hoàn thành hướng dẫn, quản lý tài khoản và phòng, chỉnh giá cửa hàng hay bật sự kiện — lưu vào database và áp dụng ngay cho mọi phòng. Lỗi JavaScript phía người chơi được gửi về server và ghi log cùng cảnh báo khi một phòng tick chậm. Triển khai là một lệnh: Docker Compose dựng Caddy với HTTPS tự động, server Go và PostgreSQL trên một VPS, kèm sao lưu database hằng ngày.
Kiến trúc
Babylon.js client
PWA, cảm ứng, tự hạ đồ hoạ
AudioWorklet
16kHz → ADPCM 4-bit
Protobuf / WebSocket
một schema, sinh Go + TS
room loop 20Hz
toàn quyền quyết định
interest index
chunk 128m, bán kính 90m
bot runner
lấp phòng, không lưu dữ liệu
PostgreSQL
sqlc, migration tự chạy
shared/data
JSON cân bằng, dùng chung
Caddy
HTTPS tự động
Docker Compose
một VPS, deploy một lệnh
admin + analytics
D1/D7, phễu, cấu hình
Kết quả
- Game chạy thật và chơi được ngay trên điện thoại qua một đường link — không cài đặt, không store, không plugin.
- Dựng theo cách tôi vẫn làm: viết spec trước, để AI hỗ trợ hiện thực, rồi siết bằng test, golden file và benchmark.
- Một phòng chứa tới 100 người trên một VPS nhỏ, nhờ chỉ gửi thứ nhìn thấy được và hạ nhịp mô phỏng ở phòng không ai xem.
- Voice chat toàn phòng chạy trên đúng kết nối WebSocket của game — không thêm hạ tầng, không thêm chi phí, và bỏ khung tiếng thay vì bỏ người chơi khi mạng nghẽn.
- Đủ nội dung để chơi chứ không dừng ở demo kỹ thuật: 133 loài chia năm bậc hiếm, chu kỳ ngày/đêm, boss ban đêm, đơn hàng, nhiệm vụ, bảng xếp hạng tuần và nhóm thuyền.