jamwt
jamwt•10mo ago

Building a Game on Convex

General discussion about building multiplayer games on Convex.
53 Replies
jamwt
jamwtOP•10mo ago
hey! very possible to just use convex for everything. there are quite a few games built on convex.
Jonny Boi
Jonny Boi•10mo ago
Hey Jamie! Very possible, perhaps I don't need socket.io Does convex support multiplayer capabilities?
jamwt
jamwtOP•10mo ago
yep
Jonny Boi
Jonny Boi•10mo ago
Such as rooms? Welp, i'm glad I asked for help
jamwt
jamwtOP•10mo ago
well, you'd have to model this yourself in a table called rooms or whatever
Jonny Boi
Jonny Boi•10mo ago
Oh I see
jamwt
jamwtOP•10mo ago
if you want a higher-level game-specific capabilties, perhaps other frameworks have more "built-in" then yeah, the need is to keep some sanity/consistency about their model vs. your data in convex
Jonny Boi
Jonny Boi•10mo ago
Gotcha, will start re-doing my logic to only use Convex. I just found this: https://docs.convex.dev/api/modules/server
Module: server | Convex Developer Hub
Utilities for implementing server-side Convex query and mutation functions.
Jonny Boi
Jonny Boi•10mo ago
I can probably re-do it easily following the docs
jamwt
jamwtOP•10mo ago
here's a game built on convex: https://www.convex.dev/ai-town , as is this: https://fast5.live/, and there were a bunch from a hackathon @Web Dev Cody hosted late last year
AI Town
A virtual town where AI characters live, chat and socialize
Jonny Boi
Jonny Boi•10mo ago
Wow this looks fantastic thanks a bunch! I'll go through the convex docs to learn how it's done with this platform Will let you know if I run into any hiccups
Jonny Boi
Jonny Boi•10mo ago
Hey @jamwt I wanted to double check to make sure I am following best practices. I created a room table and it contains a capacity field that contains the number of players in the room, I wish to do a query and get the first record found where capacity is 1, as that means that there is a player that is waiting for another player to join the room. The following is my query so far, but I don't believe it's correct. I also read that indexing is more efficient than using filters: https://docs.convex.dev/auth/database-auth
Storing Users in the Convex Database | Convex Developer Hub
You might want to have a centralized place that stores information about the
Jonny Boi
Jonny Boi•10mo ago
export const getWaitingRoom = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("rooms").filter(room => room.capacity === 1).take(1);
},
});
export const getWaitingRoom = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("rooms").filter(room => room.capacity === 1).take(1);
},
});
jamwt
jamwtOP•10mo ago
hey! yes, this would return exactly one room with a capacity equal to 1, if one exists. otherwise it would return [] a more idiomatic way would be to use the .first() method:
await ctx.db.query("rooms").filter(room => room.capacity === 1).first();
await ctx.db.query("rooms").filter(room => room.capacity === 1).first();
which will return the Doc<"rooms"> | null so you'll either get one room or null rather than an array that never has more than 1 item in it
Jonny Boi
Jonny Boi•10mo ago
Perfect, thanks! I do get one issue where capacity is not recognized, but I think it's just a typescript issue
Jonny Boi
Jonny Boi•10mo ago
No description
jamwt
jamwtOP•10mo ago
jamwt
jamwtOP•10mo ago
which demonstrates using a q argument to express an equality
Jonny Boi
Jonny Boi•10mo ago
Error went away, I made the changes, as per the docs ^^ Thanks again! 😎
jamwt
jamwtOP•10mo ago
no problem. and if you want really fast turnaround on questions as you're learning, sometimes is fastest to use the bot we've built in the docs. just click this icon here (the yellow highlighted one):
No description
jamwt
jamwtOP•10mo ago
and you'll get a chat session with a bot that can take on a lot of standard questions for using convex
Jonny Boi
Jonny Boi•10mo ago
Oh wow, that's super useful! Will def give it a try for next questions, that's awesome
ian
ian•10mo ago
There is an outdated repo for fast5 that does a lot of these queries: https://github.com/get-convex/fast5/tree/main/convex e.g. createOrJoinRandom
Jonny Boi
Jonny Boi•10mo ago
Thanks Ian! Will check out tonight when back from work, to update you, I got stuck yesterday evening that I plan on trying to find the solution for today. Essentially, I have two new fields in my room table called player1Ready and player2Ready, it represents a boolean. It sets it to true when a player joins the room. When both players join the room, the fields are updated correctly, however on the client side player2Ready is still false. The game will start when both these fields are true. I believe I would have to requery for the room that player2 joined to get the updated data, I have the ID dynamically already and I made a query function that takes in an ID to return back that room, but I'm having trouble seeing where I can call it, as I cannot call it in any functions in my react component. I think the solution would be for me to learn http with convex (routers, etc.) But I'm not sure if I have the right approach, if that is correct, my next step is to go through the docs regarding that Back home, I'm posting my code below so far
try {
if (waitingRoom) {
await updateRoomCapacity({
id: waitingRoom._id,
});

setQuestions(waitingRoom.questions);
setOptions(waitingRoom.options);
setCorrectAnswers(waitingRoom.correctAnswers);

const updatedWaitingRoom = useQuery(api.rooms.getRoom, {
id: waitingRoom._id,
});

if (updatedWaitingRoom) {
console.log(updatedWaitingRoom.player1Ready);
console.log(updatedWaitingRoom.player2Ready);
setClientReady(
updatedWaitingRoom.player1Ready && updatedWaitingRoom.player2Ready
);
}
}
try {
if (waitingRoom) {
await updateRoomCapacity({
id: waitingRoom._id,
});

setQuestions(waitingRoom.questions);
setOptions(waitingRoom.options);
setCorrectAnswers(waitingRoom.correctAnswers);

const updatedWaitingRoom = useQuery(api.rooms.getRoom, {
id: waitingRoom._id,
});

if (updatedWaitingRoom) {
console.log(updatedWaitingRoom.player1Ready);
console.log(updatedWaitingRoom.player2Ready);
setClientReady(
updatedWaitingRoom.player1Ready && updatedWaitingRoom.player2Ready
);
}
}
This part is causing an issue: ``
if (updatedWaitingRoom) {
console.log(updatedWaitingRoom.player1Ready);
console.log(updatedWaitingRoom.player2Ready);
setClientReady(
updatedWaitingRoom.player1Ready && updatedWaitingRoom.player2Ready
);
}
if (updatedWaitingRoom) {
console.log(updatedWaitingRoom.player1Ready);
console.log(updatedWaitingRoom.player2Ready);
setClientReady(
updatedWaitingRoom.player1Ready && updatedWaitingRoom.player2Ready
);
}
Jonny Boi
Jonny Boi•10mo ago
No description
Jonny Boi
Jonny Boi•10mo ago
calling it in an async function in my react component
Michal Srb
Michal Srb•10mo ago
Hey @Jonny Boi, have you gone through our tutorial? It helps to understand how apps should be modeled using automatically reactive queries and mutations. If you do need to fetch a query (most of the time, you don't), you can use const client = useConvex() and await client.query(...), see https://docs.convex.dev/client/react#one-off-queries
Convex React | Convex Developer Hub
Convex React is the client library enabling your React application to interact
Jonny Boi
Jonny Boi•10mo ago
Thanks Michal! Will check out, much appreciated 🙂 I think I found a more clear way to indicate the issue, but going more-over docs to see if can find solution
if (waitingRoomQuery) {
await updateRoomCapacity({
id: waitingRoomQuery._id,
});

setQuestions(waitingRoomQuery.questions);
setOptions(waitingRoomQuery.options);
setCorrectAnswers(waitingRoomQuery.correctAnswers);
console.log(waitingRoomQuery.player1Ready);
console.log(waitingRoomQuery.player2Ready);
if (waitingRoomQuery) {
await updateRoomCapacity({
id: waitingRoomQuery._id,
});

setQuestions(waitingRoomQuery.questions);
setOptions(waitingRoomQuery.options);
setCorrectAnswers(waitingRoomQuery.correctAnswers);
console.log(waitingRoomQuery.player1Ready);
console.log(waitingRoomQuery.player2Ready);
after the query updateRoomCapacity, waitingRoomQuery is still setting/console.logging the old values Which is why I thought I had to re-fetch it, but I think I had the wrong terminology, since convex already supports real time updates
ian
ian•10mo ago
also, when you set the second of player1Ready / player2Ready, you can do all of the work there, rather than doing it from the client side. Convex mutations are transactional so you won't have a data race where neither think they're the first one to get set
Jonny Boi
Jonny Boi•10mo ago
Thanks Ian for the tip, yeah I'm only setting it in the backend for player1Ready and player2Ready. When a room is created, I set player1Ready to true, since a room will only be created if it was not able to find an existing room with capacity of 1. If a room of capacity 1 is found, it will set player2Ready to true in the backend since now the room has 2 players to start a game But even though player2Ready is set to true in the backend, console.log(waitingRoomQuery.player2Ready); still prints the previous value of 1 that's why I thought originally that I may have to re-query it but the more I think about it, I realize that since convex is real time, the moment that capacity becomes two, it no longer becomes a valid result in the query I am doing, so I'm working on finding a different approach
ian
ian•10mo ago
you can have a query on the game that the current user is in, which will appear once a user is added to a game
Jonny Boi
Jonny Boi•10mo ago
Hi Ian, yup that's what I've been doing, but having the same issue When a player joins a room with no capacity of 1, it will create a new room for the player, as no other players are waiting
Jonny Boi
Jonny Boi•10mo ago
No description
Jonny Boi
Jonny Boi•10mo ago
Then when the next player joins a room, it will see that there exists a room with capacity 1 So it updates that room with capacity 2, since that player joined the room, as well as sets player2Ready to true and updates the status of the room
Jonny Boi
Jonny Boi•10mo ago
No description
Jonny Boi
Jonny Boi•10mo ago
These updates are done in the backend, but the client triggers these updates accordingly However, the issue is that even though the data updated correctly in the database it still returns the old values when I do a console.log
jamwt
jamwtOP•10mo ago
@Jonny Boi where are you doing the console.log? in your app?
Jonny Boi
Jonny Boi•10mo ago
I'm doing it right after the mutation to the waitingRoomQuery. Example:
if (waitingRoomQuery) {
await updateRoomCapacity({
id: waitingRoomQuery._id,
});

console.log(waitingRoomQuery.player1Ready);
if (waitingRoomQuery) {
await updateRoomCapacity({
id: waitingRoomQuery._id,
});

console.log(waitingRoomQuery.player1Ready);
I wrote another query to actually fetch when both player1Ready states are true (set in backend) and re-working some of the code to see if can get it to work
jamwt
jamwtOP•10mo ago
@sujayakar what's the semantic here regarding read-your-writes on a subscription?
lee
lee•10mo ago
the guarantee is that the mutation's promise will resolve after the queries have rerendered with the new data if i'm understanding correctly, the code looks like
function Component() {
const waitingRoomQuery = useQuery(api.getWaitingRoom);
useEffect(async () => {
if (waitingRoomQuery) {
await updateRoomCapacity({id: waitingRoomQuery._id});
console.log(waitingRoomQuery.player1Ready);
}
});
}
function Component() {
const waitingRoomQuery = useQuery(api.getWaitingRoom);
useEffect(async () => {
if (waitingRoomQuery) {
await updateRoomCapacity({id: waitingRoomQuery._id});
console.log(waitingRoomQuery.player1Ready);
}
});
}
in this case the Component will rerender before the updateRoomCapacity promise returns -- that's our "read your own writes" guarantee. But since waitingRoomQuery is a variable from the first render, captured by the useEffect, waitingRoomQuery.player1Ready will be printed as false am i misunderstanding the code flow?
Jonny Boi
Jonny Boi•10mo ago
Ah that makes sense I have it setup like the following:
const joinRoom = async () => {
try {
if (waitingRoomQuery) {
await updateRoomCapacity({
id: waitingRoomQuery._id,
});

console.log(waitingRoomQuery.player1Ready);
}
const joinRoom = async () => {
try {
if (waitingRoomQuery) {
await updateRoomCapacity({
id: waitingRoomQuery._id,
});

console.log(waitingRoomQuery.player1Ready);
}
Where joinRoom is triggered from a button click:
<button onClick={joinRoom}>Join a Room</button>
<button onClick={joinRoom}>Join a Room</button>
I tried in a useEffect as well, but had same behavior
lee
lee•10mo ago
yeah this is just how javascript works variables don't change out from under you
Jonny Boi
Jonny Boi•10mo ago
Do you have a recommendation on how I can get around this?
if (waitingRoomQuery) {
await updateRoomCapacity({
id: waitingRoomQuery._id,
});

console.log(waitingRoomQuery.player1Ready);
if (waitingRoomQuery) {
await updateRoomCapacity({
id: waitingRoomQuery._id,
});

console.log(waitingRoomQuery.player1Ready);
For example, I'm not sure if I can get the updated value from updateRoomCapacity in a more efficient manner and use that in the console.log example
lee
lee•10mo ago
a few options: 1. the mutation can return a value, so you can do waitingRoomQuery = await updateRoomCapacity(...) 2. you can refactor to trigger the console.log (or whatever) when the component rerenders with the new query value it depends what you want to do
Jonny Boi
Jonny Boi•10mo ago
Thanks Lee! I made the changes to my mutation to re-fetch the document id in order to return it and it's working now 🙂 thank you so much everyone! Hey @lee Another question for you Does the concept of listening for event changes exist in convex? Context: I'm making progress with the multiplayer, however, when the second player joins the room, the game starts successfully but for the first player that joined the room, nothing is updated for them, so trying to think of a way for player 1 to be notified when player 2 joined the room
lee
lee•10mo ago
"listening for event changes" sounds like the same thing as "subscribing to a reactive useQuery" to me
Jonny Boi
Jonny Boi•10mo ago
As I've been trying with my queries, but no luck so far
lee
lee•10mo ago
player 1 should have a query for "other player in the room with me"
Jonny Boi
Jonny Boi•10mo ago
Yup, but I can't seem to get it to update for player 1, even if player 1 is subscribed to a reactive useQuery Roger, going to re-work to get a query more so like that
lee
lee•10mo ago
if the subscription isn't triggering when it should be, could you share the query and the mutation that should rerun it?
Jonny Boi
Jonny Boi•10mo ago
I have a query that gets game's in the ready state after a player joined the room, but your suggestion sounds more optimal
export const getGameReady = query({
args: { },
handler: async (ctx) => {
return await ctx.db.query("rooms").filter((q) => (q.eq(q.field("player1Ready"), true) && (q.eq(q.field("player2Ready"), true) && (q.eq(q.field("status"), GameStatus.WaitingForPlayers))))).first();
},
});
export const getGameReady = query({
args: { },
handler: async (ctx) => {
return await ctx.db.query("rooms").filter((q) => (q.eq(q.field("player1Ready"), true) && (q.eq(q.field("player2Ready"), true) && (q.eq(q.field("status"), GameStatus.WaitingForPlayers))))).first();
},
});
That's the query that gets a game that is ready but as I copied the code I realize my mistake enum GameStatus { WaitingForPlayers = 'Waiting for players', InProgress = 'In progress', Completed = 'Completed', } that's the enum ^^ I put WaitingForPlayers instead of InProgress going to correct and see if works Nevermind same issue after update will share mutation too This is the query that gets a room with capacity 1, meaning a player is waiting for another player
export const getWaitingRoom = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("rooms").filter((q) => q.eq(q.field("capacity"), 1)).first();
},
});
export const getWaitingRoom = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("rooms").filter((q) => q.eq(q.field("capacity"), 1)).first();
},
});
and this is the mutation that updates the capacity to 2, it is triggered when a second player enters a room and getWaitingRoom returns a value
export const updateRoomCapacity = mutation({
args: { id: v.id("rooms")},
handler: async (ctx, args) => {
const { id } = args;

await ctx.db.patch(id, {capacity: 2, status: GameStatus.InProgress, player2Ready: true});
const updatedRoom = await ctx.db.get(id);
return updatedRoom;
},
})
export const updateRoomCapacity = mutation({
args: { id: v.id("rooms")},
handler: async (ctx, args) => {
const { id } = args;

await ctx.db.patch(id, {capacity: 2, status: GameStatus.InProgress, player2Ready: true});
const updatedRoom = await ctx.db.get(id);
return updatedRoom;
},
})
I pass in the ID of the getWaitingRoom result in order to update it
lee
lee•10mo ago
using && in a query filter doesn't work. you can use q.and. or you can use a filter like described in https://stack.convex.dev/complex-filters-in-convex
Using TypeScript to Write Complex Query Filters
There’s a new Convex helper to perform generic TypeScript filters, with the same performance as built-in Convex filters, and unlimited potential.
Jonny Boi
Jonny Boi•10mo ago
ahh gotcha, I made the correction Thanks! Same issue though, going to try your suggestion and make a players table This is a good challenge, still working on it, but learning lots, even if don't finish, will still make presentation video and submit 💪