-2

So, I'm looking to create an idle online game with many players.

I've designed 5 tables so far, yet I'm not sure how optimised/normalised they are. Especially the user_equipment table full of foreign keys. The inventory table also feels like it would very quickly fill up.

inventory - Would this not become populated very quickly?

item_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
count INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY(user_id) REFERENCES users(user_id),
FOREIGN KEY(item_id) REFERENCES items(item_id),
PRIMARY KEY(item_id, user_id)

items - Stores information about all items in the game

item_id INTEGER NOT NULL,
name TEXT NOT NULL,
attack INTEGER,
defense INTEGER,
perception INTEGER,
special TEXT DEFAULT "x",
description TEXT NOT NULL,
PRIMARY KEY(item_id)

user_equipment - This table is a primary concern. Stores IDs of equipped items.

user_id TEXT NOT NULL,
head_id INTEGER,
body_id INTEGER,
hand_id INTEGER,
feet_id INTEGER,
main_hand_id INTEGER,
off_hand_id INTEGER,
FOREIGN KEY(user_id) REFERENCES users(user_id),
FOREIGN KEY(head_id) REFERENCES items(item_id),
FOREIGN KEY(body_id) REFERENCES items(item_id),
FOREIGN KEY(hand_id) REFERENCES items(item_id),
FOREIGN KEY(feet_id) REFERENCES items(item_id),
FOREIGN KEY(main_hand_id) REFERENCES items(item_id),
FOREIGN KEY(off_hand_id) REFERENCES items(item_id),
PRIMARY KEY(user_id)

user_stats - Stores stats for every user.

user_id TEXT NOT NULL,
attack INTEGER DEFAULT 100,
defense INTEGER DEFAULT 100,
perception INTEGER DEFAULT 5,
FOREIGN KEY(user_id) REFERENCES users(user_id),
PRIMARY KEY(user_id)

users - Stores all users.

user_id TEXT NOT NULL,
spirit_stones INTEGER DEFAULT 0,
cultivation_val INTEGER DEFAULT 0,
last_checked DATE NOT NULL,
PRIMARY KEY(user_id)

Are the inventory and user_equipment tables fine? Is there a better way to design and go about this? I'd be grateful for any help and advice!

philipxy
  • 14,867
  • 6
  • 39
  • 83
  • Hi, and welcome to Stack Overflow. This looks ok so far, are there any indexes? Can you explain what you mean by "The inventory table also feels like it would very quickly ***fill up***"? We'd need to know some things about how this is to be used. What is the intended sizes of various tables? Roughly how many users do you expect? 10? 1,000? 1,000,000? How many items? How big an inventory? – Schwern May 17 '20 at 18:18
  • Indexes are like a card catalogue in a library (if that analogy still works). Without an index, the database must search the entire table. With a database, it can look up the matching rows quickly. They are extremely important to getting a database to continue to perform well as table size grows. Keys and unique values all have indexes, and you can add your own. [Here's more on indexes](https://stackoverflow.com/questions/2955459/what-is-an-index-in-sql). – Schwern May 17 '20 at 18:31
  • Yes, but foreign keys are already indexed. – Schwern May 17 '20 at 18:58

1 Answers1

1

This all looks like a pretty good start for a small RPG. Here's some suggestions.


users.user_id should be an integer. Names change, but IDs should not. Add a name column. In general, primary keys for data tables (distinct from join tables) should be simple integers.


Consider user_equipment. Having a bunch of identical columns is a red flag.

While you have a fixed number of inventory slots, consider how you'd search if a player has a given item equipped.

select 1
from user_equipment
where user_id = :user_id
  and :item_id in (head_id, body_id, hand_id, feet_id, main_hand_id, off_hand_id)

When you need to add or remove a new slot, the (increasingly large) table must be altered, and all the queries, too. And you need six indexes.

Instead, have a table to store the types of slots, and whatever other information you need. Then a table to store what's in each slot for each user. This simplifies indexing and searching.

create slots (
  -- You can also use an integer.
  -- I chose char(2) because it is easier to know what it is without a join.
  slot_id char(2) primary key,
  name text not null
);

insert into slots values
('H', 'Head'), ('B', 'Body'), ('MH', 'Main Hand'),
('OH', 'Off Hand'), ('F', 'Feet');

create user_slots (
  user_id integer not null references users(id),
  item_id integer not null references items(id),
  slot_id char(2) not null references slots(slot_id),

  -- Enforce only one item per user slot
  unique(user_id, slot_id)
)

Now querying if a user has an item equipped is easy.

select 1
from user_slots
where user_id = :user_id
  and item_id = :item_id

Or all of a user's equipment, and what slot its in.

select slots.name, item_id
from user_slots us
join slots on us.slot_id = slots.slot_id
where user_id = :user_id

Or you can find who has an item equipped and in what slot.

select user_id, item_id, slots.name
from user_slots us
join slots on us.slot_id = slots.slot_id
where item_id = :item_id

Indexing is important to keep performance the same as the tables grow. For example, all the foreign keys are indexed. If you search by user_id or item_id SQLite won't have to search the whole table. But if you search by items.name, which has no indexes, it will have to search the whole items table.

Indexing is a very big topic. You have to think about how tables will grow and how they will be searched. For example, users will grow unbounded. items will likely only get so large. user_equipped will grow as users grow, but an individual user will only have so many items; since user_id and item_id are indexed you're likely to be fine.

For example, checking if a user has an item equipped will perform well because all the foreign keys are indexed.

select user_id, item_id, slots.name
from user_slots us
join slots on us.slot_id = slots.slot_id
where item_id = :item_id

But looking for users who haven't been checked in a certain time will have to search the whole table unless users.last_checked is indexed.

select user_id
from users
where last_checked < :time

Similarly, indexes are also used for ordering. This query will perform poorly unless users.last_checked is indexed.

select user_id
from users
order by last_checked desc

(And consider whether last_checked should be a


Unless there's a good reason to separate it, user_stats should be merged into users.

users

user_id TEXT NOT NULL,
attack INTEGER DEFAULT 100,
defense INTEGER DEFAULT 100,
perception INTEGER DEFAULT 5,
spirit_stones INTEGER DEFAULT 0,
cultivation_val INTEGER DEFAULT 0,
last_checked DATE NOT NULL,
PRIMARY KEY(user_id)

If there is a good reason to separate it, put all the user stats in the same place.

user_stats

user_id TEXT NOT NULL,
attack INTEGER DEFAULT 100,
defense INTEGER DEFAULT 100,
perception INTEGER DEFAULT 5,
spirit_stones INTEGER DEFAULT 0,
cultivation_val INTEGER DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(user_id),
PRIMARY KEY(user_id)

users - Stores all users.

user_id TEXT NOT NULL,
last_checked DATE NOT NULL,
PRIMARY KEY(user_id)

One reason to separate user_stats is you're going to be updating the stats a lot. This might involve row locking. Putting the stats in a separate table avoids locking the vital users table.

Schwern
  • 153,029
  • 25
  • 195
  • 336
  • Amazing. Thanks for the help! The only reason I wanted to separate the `user_stats` table is because this is just a foundation, and I may end up adding more stats, so if I combined it with the `users` table, it may result in nearly 20 columns. I was worried this would affect search efficiency or performance. Also, the SQL for search "all of a user's equipment, and what slot it's in" doesn't query `user_id` with it, so surely it wouldn't fetch the data for a specific user? –  May 17 '20 at 19:44
  • I also planned on adding `missions` and `active_missions` tables, but was unsure about how to store the `rewards` since there may be several of them, and storing several values in a single column would violate 1NF. As a result, I was going to store `mission_id` and `rewards` in a JSON file instead, which I could then query. I'm not sure if it's good practice to use a DB for one part of the data, and another format like JSON for a different part? –  May 17 '20 at 19:47
  • 20 integer columns are fine, splitting the table up is a premature optimization / I fixed the query, good eye. / Yes, splitting your data storage, except to seed the database, is not a good idea at this stage. Missions / Rewards would be a 1-to-many relationship if they're unique to the mission, or many-to-many (with a join table) if many missions can have the same rewards. – Schwern May 17 '20 at 23:01