Thiết kế metadata cho game vật phẩm: lưu speed, damage, health như thế nào để vừa linh hoạt vừa dễ mở rộng
Trong các dự án game có cửa hàng vật phẩm, một câu hỏi tưởng nhỏ nhưng ảnh hưởng rất lớn đến khả năng mở rộng hệ thống là: nên lưu các thuộc tính như speed, damage, health ở đâu?
Nếu viết cứng các chỉ số này trong mã nguồn, đội ngũ có thể làm nhanh ở giai đoạn đầu nhưng sẽ rất khó mở rộng khi game có thêm xe tăng mới, ngoại hình mới, độ hiếm, kỹ năng bị động hay các biến thể theo sự kiện. Nếu nhồi tất cả vào bảng product, cấu trúc dữ liệu sẽ ngày càng cồng kềnh và thiếu linh hoạt. Một hướng đi thực tế hơn là dùng mô hình metadata cho vật phẩm game: phần dữ liệu lõi vẫn nằm ở product, còn các thuộc tính phục vụ lối chơi được tách ra thành metadata.
Trong project EzyTank, đây chính là cách đang được áp dụng. Các xe tăng trong cửa hàng được quản lý như sản phẩm của hệ thống thương mại điện tử, còn các chỉ số như speed, damage, health được lưu dưới dạng metadata theo từng sản phẩm. Cách làm này vừa tận dụng được hạ tầng có sẵn của EzyPlatform, vừa giữ cho lớp xử lý lối chơi đủ mềm để phát triển lâu dài.

Bài toán thật sự không phải là lưu 3 con số

Thoạt nhìn, speed, damage, health chỉ là ba trường dữ liệu đơn giản. Nhưng nếu nhìn xa hơn một chút, chúng đại diện cho cả một nhóm thuộc tính lối chơi có thể thay đổi theo thời gian.
Hôm nay chúng ta có:
  • speed
  • damage
  • health
Ngày mai có thể xuất hiện thêm:
  • armor
  • criticalRate
  • reloadTime
  • rarity
  • skillCooldown
  • projectileType
Nếu mỗi lần thêm thuộc tính mới đều phải sửa cấu trúc bảng sản phẩm, sửa lớp truyền dữ liệu, sửa lớp chuyển đổi, sửa giao diện quản trị, sửa dữ liệu khởi tạo, sửa giao diện lập trình ứng dụng và sửa cả phía Unity theo kiểu cứng, chi phí bảo trì sẽ tăng rất nhanh. Vấn đề ở đây không nằm ở số lượng trường hiện tại, mà nằm ở việc thiết kế sao cho hệ thống chịu được thay đổi.

Cách EzyTank đang tiếp cận bài toán

Trong project này, xe tăng vẫn được lưu như một sản phẩm trong cửa hàng. Những thông tin cốt lõi như:
  • tên xe tăng
  • mã xe tăng
  • giá
  • số lượng
  • ảnh biểu tượng
  • cửa hàng sở hữu
  • trạng thái hiển thị
được lưu ở lớp sản phẩm chuẩn.
Còn các thuộc tính phục vụ lối chơi được tách ra thành metadata. Khi nạp dữ liệu mặc định trong phần quản trị, hệ thống tạo sản phẩm trước, sau đó lưu từng metadata như speed, damage, health cho sản phẩm đó.
Luồng này có thể mô tả như sau:
flowchart TD
    A[default-data.json] --> B[AdminDefaultDataConfig]
    B --> C[Tao san pham]
    B --> D[Luu metadata speed]
    B --> E[Luu metadata damage]
    B --> F[Luu metadata health]
    C --> G[(Product)]
    D --> H[(Product Meta)]
    E --> H
    F --> H
Đây là một lựa chọn rất đáng chú ý vì nó tách rõ hai lớp dữ liệu:
  • dữ liệu thương mại
  • dữ liệu lối chơi
Sự tách biệt này giúp hệ thống không bị lệch vai. Phần thương mại vẫn xử lý giá, đơn vị tiền tệ, đơn hàng, giỏ hàng. Phần lối chơi chỉ cần đọc thêm metadata để hiển thị vật phẩm đúng nghĩa trong game.

Vì sao metadata là lựa chọn hợp lý

Linh hoạt khi thêm thuộc tính mới

Khi muốn thêm armor, đội ngũ gần như không phải thay đổi mô hình sản phẩm lõi. Chỉ cần bổ sung metadata mới cho các xe tăng tương ứng và cập nhật lớp ánh xạ hoặc phản hồi nếu muốn trả ra cho phía người chơi.
Điểm mạnh ở đây là khả năng mở rộng theo chiều ngang. Ta không cần đoán trước tất cả các trường mà game sẽ có sau 6 tháng hay 1 năm.

Tái sử dụng hạ tầng sản phẩm có sẵn

EzyTank dùng lại hạ tầng của EzyPlatform cho:
  • quản lý cửa hàng
  • quản lý sản phẩm
  • quản lý giá
  • quản lý tiền tệ
  • quản lý đơn hàng
  • quản lý số dư
Nếu gộp toàn bộ logic lối chơi vào bảng sản phẩm hoặc dựng riêng một hệ hoàn toàn tách biệt, đội ngũ sẽ mất đi lợi ích của việc tái sử dụng nền tảng. Metadata giúp nối phần lối chơi vào phần thương mại theo cách ít xâm lấn nhất.

Dễ nạp dữ liệu và quản trị

Trong default-data.json, mỗi xe tăng có thể được mô tả rất tự nhiên:
  • dữ liệu cấp sản phẩm như name, code, price, iconImagePath
  • dữ liệu cấp metadata như speed, damage, health
Cách này rất thuận tiện cho việc cài đặt mô-đun, tạo bản mẫu, hoặc triển khai ở nhiều môi trường khác nhau.

Giữ ổn định cấu trúc lõi

Cấu trúc lõi càng ổn định thì rủi ro thay đổi dữ liệu càng thấp. Đây là một lợi ích rất thực dụng, đặc biệt khi sản phẩm đã có dữ liệu thực tế, có màn hình quản trị, có giao diện lập trình ứng dụng cho khách dùng và có thêm Unity hoặc WebGL dùng chung.

Kiến trúc dữ liệu nên chia thế nào

Một cách nhìn đơn giản là chia vật phẩm thành 2 lớp.
classDiagram
    class Product {
        +id
        +code
        +name
        +price
        +currencyId
        +iconImageId
        +status
        +shopId
    }

    class ProductMeta {
        +productId
        +metaKey
        +metaValue
    }

    class TankStats {
        +speed
        +damage
        +health
    }

    Product "1" --> "*" ProductMeta : so huu
    TankStats ..> ProductMeta : duoc tao tu
Trong đó:
  • Product là dữ liệu chung cho phần thương mại
  • ProductMeta là nơi lưu thuộc tính mở rộng
  • TankStats là mô hình trung gian ở tầng nghiệp vụ để gom các metadata liên quan tới lối chơi
Ở EzyTank, mô hình này thể hiện khá rõ qua TankProductMetaModel, nơi các chỉ số được gom lại thành một đối tượng có nghĩa với bài toán nghiệp vụ.

Luồng đọc dữ liệu từ metadata lên phản hồi giao diện lập trình ứng dụng

Khi phía người chơi gọi giao diện lấy danh sách xe tăng, phần máy chủ không chỉ trả về thông tin sản phẩm cơ bản. Hệ thống còn làm giàu dữ liệu bằng cách:
  • lấy danh sách sản phẩm xe tăng
  • lấy giá theo đồng tiền số mặc định
  • lấy ảnh biểu tượng
  • lấy tập xe tăng mà người chơi đã mua
  • lấy metadata speed
  • lấy metadata damage
  • lấy metadata health
  • ghép toàn bộ thành dữ liệu trả về cho phía người chơi
Luồng này rất hay vì nó cho thấy metadata không làm hệ thống rời rạc, miễn là ta có một lớp tổng hợp dữ liệu tốt.
sequenceDiagram
    participant NguoiChoi
    participant API as ApiTankShopController
    participant DichVu as WebTankProductControllerService
    participant TrangTri as TankProductModelDecorator
    participant SanPham as ProductService
    participant Meta as ProductMetaService
    participant Gia as ProductPriceService
    participant HinhAnh as MediaService

    NguoiChoi->>API: GET /api/v1/tank-products
    API->>DichVu: getTankProductsByUserId(userId)
    DichVu->>SanPham: getAllTankProducts()
    DichVu->>TrangTri: decorate(userId, tankProducts)
    TrangTri->>Gia: getProductPriceMap(...)
    TrangTri->>HinhAnh: getMediaNameMapByIds(...)
    TrangTri->>Meta: lay speed
    TrangTri->>Meta: lay damage
    TrangTri->>Meta: lay health
    TrangTri->>SanPham: filterPaidTankProductIds(...)
    TrangTri-->>API: TankProductResponse[]
    API-->>NguoiChoi: tankProducts
Điểm đáng học ở đây là không để lớp điều khiển ôm quá nhiều logic. Lớp điều khiển chỉ nhận yêu cầu và trả kết quả. Việc kết hợp sản phẩm, hình ảnh, giá, quyền sở hữu và metadata được đẩy xuống lớp dịch vụ hoặc lớp trang trí chuyên trách.

Metadata giúp mở rộng, nhưng không có nghĩa là muốn lưu gì cũng được

Đây là chỗ rất nhiều hệ thống bị trượt. Khi đã có metadata, đội ngũ dễ rơi vào tư duy “cứ thêm khóa là xong”. Nếu không có quy ước rõ ràng, metadata sẽ biến thành một chiếc ngăn kéo lộn xộn.
Một thiết kế metadata tốt cần có ít nhất các nguyên tắc sau.

Có quy ước đặt tên rõ ràng

Ví dụ:
  • speed
  • damage
  • health
  • armor
  • reload_time
  • critical_rate
Đừng để cùng một ý nghĩa nhưng tồn tại nhiều khóa như:
  • hp
  • health_point
  • health
  • tank_health

Có quy ước kiểu dữ liệu

Nếu metadata chỉ lưu chuỗi, tầng ứng dụng phải biết cách chuyển đổi chính xác. Với các khóa liên quan tới lối chơi, nên quy định rõ:
  • speed: số nguyên
  • damage: số nguyên
  • health: số nguyên
  • critical_rate: số thập phân
  • rarity: chuỗi theo tập giá trị định sẵn

Phân nhóm metadata theo lĩnh vực

Không phải metadata nào cũng cùng loại. Có thể chia thành:
  • chỉ số lối chơi
  • cấu hình hiển thị
  • điều kiện mở khóa
  • cờ phục vụ kiếm tiền
  • cấu hình sự kiện
Nếu không phân nhóm, về lâu dài giao diện lập trình ứng dụng và phần quản trị sẽ rất khó hiểu.

Có tầng ánh xạ sang mô hình nghiệp vụ

Metadata không nên đi thẳng từ nơi lưu trữ ra phía người chơi dưới dạng cặp khóa - giá trị thô trong mọi trường hợp. Với những phần quan trọng như chỉ số xe tăng, nên ánh xạ sang mô hình có nghĩa, ví dụ TankProductMetaModel. Cách này giúp mã nguồn dễ đọc, dễ kiểm tra và dễ kiểm soát phiên bản hơn.

Khi nào nên dùng metadata, khi nào không nên

Metadata không phải lời giải cho mọi bài toán. Có vài nguyên tắc thực tế như sau.
Nên dùng metadata khi:
  • thuộc tính có khả năng thay đổi, mở rộng theo thời gian
  • không phải mọi sản phẩm đều có cùng một bộ trường
  • muốn tái sử dụng cấu trúc sản phẩm lõi
  • cần triển khai nhanh nhiều biến thể vật phẩm
Không nên lạm dụng metadata khi:
  • trường dữ liệu là bắt buộc, cốt lõi, xuất hiện ở mọi sản phẩm
  • trường dữ liệu tham gia nhiều vào lọc, sắp xếp, truy vấn ở mức cơ sở dữ liệu
  • trường dữ liệu cần ràng buộc mạnh bằng cấu trúc
  • trường dữ liệu có logic phức tạp tới mức xứng đáng có một mô hình riêng
Ví dụ, price, status, shopId rõ ràng nên nằm ở sản phẩm. Nhưng speed, damage, health, armor, specialAbility lại phù hợp hơn với metadata hoặc một mô hình mở rộng.

Một hướng mở rộng tốt hơn cho tương lai

Hiện tại EzyTank đang lấy từng khóa metadata riêng biệt như speed, damage, health. Cách này đủ tốt cho quy mô nhỏ và rất dễ hiểu. Nhưng khi số lượng thuộc tính tăng lên, đội ngũ có thể cân nhắc một tầng trừu tượng rõ hơn.
Ví dụ, thay vì lấy từng khóa riêng lẻ, ta có thể quy hoạch một nhóm chỉ số chuẩn:
flowchart LR
    A[(Product Meta)] --> B[Bo anh xa chi so]
    B --> C[Dinh nghia chi so xe tang]
    C --> D[TankProductMetaModel]
    D --> E[Phan hoi API]
    E --> F[Phia Unity]
Lợi ích của hướng này là:
  • tập trung logic chuyển đổi và kiểm tra ở một nơi
  • dễ thêm chỉ số mới
  • giảm lặp mã khi ghép dữ liệu trả về
  • thuận tiện hơn khi quản lý phiên bản cho phía người chơi
Một bước xa hơn nữa là xây dựng bộ định nghĩa chỉ số dưới dạng cấu hình, để người thiết kế lối chơi và đội ngũ máy chủ có cùng một ngôn ngữ dữ liệu.

Rủi ro cần lưu ý khi đi theo hướng metadata

Thiết kế này tốt, nhưng cũng cần nhìn rõ các rủi ro.

Rủi ro về hiệu năng

Nếu mỗi vật phẩm phải truy vấn metadata nhiều lần, số lượng lần đọc dữ liệu sẽ tăng. EzyTank đã xử lý khá ổn bằng cách lấy bản đồ metadata theo danh sách mã sản phẩm rồi gom lại ở tầng trang trí dữ liệu. Đây là điểm rất quan trọng.

Rủi ro về sai kiểu dữ liệu

Nếu metadata speed được nhập nhầm thành văn bản không chuyển được sang số, dữ liệu trả về có thể lỗi hoặc cho kết quả sai. Vì vậy phần quản trị hoặc phần dịch vụ nên kiểm tra sớm.

Rủi ro về thiếu chuẩn hóa

Nếu hôm nay dùng damage, mai lại có atk, phía người chơi sẽ bị phức tạp hóa. Metadata linh hoạt, nhưng linh hoạt phải đi cùng kỷ luật.

Rủi ro về hợp đồng dữ liệu

Phía Unity thường muốn dữ liệu có cấu trúc ổn định. Nếu máy chủ trả metadata quá động, phía người chơi sẽ khó bảo trì. Vì vậy một cách làm tốt là: lưu linh hoạt ở tầng dữ liệu, nhưng trả ra có cấu trúc ở tầng giao diện lập trình ứng dụng.

Bài học rút ra từ EzyTank

Điểm đáng giá của EzyTank không chỉ là có lưu metadata, mà là cách project đặt metadata đúng vị trí trong kiến trúc tổng thể:
  • sản phẩm giữ vai trò thương mại
  • metadata giữ vai trò mở rộng cho lối chơi
  • lớp dịch vụ tổng hợp dữ liệu
  • lớp trang trí đóng gói dữ liệu trả về
  • phía Unity nhận dữ liệu đã được định hình
Đây là một hướng đi rất hợp với các dự án game hoặc mô-đun trên EzyPlatform, nơi sản phẩm không chỉ cần chạy được, mà còn cần dễ đóng gói, dễ cài đặt, dễ mở rộng và tận dụng được các thành phần sẵn có của nền tảng.

Kết luận

Nếu dự án game của bạn chỉ có vài vật phẩm và gần như không thay đổi, viết cứng có thể vẫn dùng được một thời gian. Nhưng nếu bạn đang xây một hệ thống có cửa hàng, có nhiều loại xe tăng, có vòng đời sản phẩm dài và có khả năng thêm thuộc tính lối chơi liên tục, thì metadata là một lựa chọn rất đáng cân nhắc.
Giá trị thật của cách làm này nằm ở chỗ nó cho phép hệ thống thay đổi mà không cần đập đi xây lại. Bạn vẫn giữ được sự ổn định ở lớp thương mại, trong khi phần lối chơi có đủ không gian để tiến hóa.
Nói ngắn gọn: đừng thiết kế speed, damage, health như 3 trường dữ liệu tĩnh. Hãy xem chúng là điểm khởi đầu của một mô hình vật phẩm có thể mở rộng.
Nếu muốn, mình có thể biên tập tiếp bài này theo đúng giọng blog EzyPlatform hơn nữa, rút thêm tiếng Anh còn lại như metadata, product, API, model sang cách diễn đạt thuần Việt hoặc bán thuần Việt.