Skip to content

Commit

Permalink
feat: use kv watch to synchronize state (#5)
Browse files Browse the repository at this point in the history
  • Loading branch information
losfair authored Dec 11, 2023
1 parent 0fc4d87 commit 2800ce2
Show file tree
Hide file tree
Showing 8 changed files with 470 additions and 241 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
.env
.env
# Fresh build directory
_fresh/
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@

This is a global, real-time multiplayer Tic-Tac-Toe game written in Deno. It
persists game states in a Deno KV store, and synchronizes game state between
clients using BroadcastChannel.
clients using the `watch()` feature of Deno KV.

## Features

- Real-time multiplayer game
- Global persistent game state using Deno KV
- Synchronizes game state between clients using BroadcastChannel
- Persistent game state and coordination using Deno KV
- Uses GitHub OAuth for authentication

This project is hosted on Deno Deploy:
Expand Down
26 changes: 20 additions & 6 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
{
"tasks": {
"start": "deno run -A --unstable --watch=static/,routes/ dev.ts"
"start": "deno run -A --unstable --watch=static/,routes/ dev.ts",
"build": "deno run -A dev.ts build",
"preview": "deno run -A main.ts"
},
"importMap": "./import_map.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "preact"
}
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
"imports": {
"🛠️/": "./utils/",
"🏝️/": "./islands/",
"🧱/": "./components/",
"$fresh/": "https://deno.land/x/[email protected]/",
"preact": "https://esm.sh/[email protected]",
"preact/": "https://esm.sh/[email protected]/",
"preact-render-to-string": "https://esm.sh/*[email protected]",
"@preact/signals": "https://esm.sh/*@preact/[email protected]",
"@preact/signals-core": "https://esm.sh/*@preact/[email protected]",
"twind": "https://esm.sh/[email protected]",
"twind/": "https://esm.sh/[email protected]/",
"$std/": "https://deno.land/[email protected]/"
},
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
"exclude": ["**/_fresh/*"]
}
484 changes: 363 additions & 121 deletions deno.lock

Large diffs are not rendered by default.

57 changes: 29 additions & 28 deletions fresh.gen.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,41 @@
// DO NOT EDIT. This file is generated by fresh.
// DO NOT EDIT. This file is generated by Fresh.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.

import config from "./deno.json" assert { type: "json" };
import * as $0 from "./routes/_middleware.tsx";
import * as $1 from "./routes/api/events/game.ts";
import * as $2 from "./routes/api/events/games.ts";
import * as $3 from "./routes/api/place.ts";
import * as $4 from "./routes/auth/oauth2callback.ts";
import * as $5 from "./routes/auth/signin.ts";
import * as $6 from "./routes/auth/signout.ts";
import * as $7 from "./routes/game/[id].tsx";
import * as $8 from "./routes/index.tsx";
import * as $9 from "./routes/start.tsx";
import * as $$0 from "./islands/GameDisplay.tsx";
import * as $$1 from "./islands/GamesList.tsx";
import * as $_app from "./routes/_app.tsx";
import * as $_middleware from "./routes/_middleware.tsx";
import * as $api_events_game from "./routes/api/events/game.ts";
import * as $api_events_games from "./routes/api/events/games.ts";
import * as $api_place from "./routes/api/place.ts";
import * as $auth_oauth2callback from "./routes/auth/oauth2callback.ts";
import * as $auth_signin from "./routes/auth/signin.ts";
import * as $auth_signout from "./routes/auth/signout.ts";
import * as $game_id_ from "./routes/game/[id].tsx";
import * as $index from "./routes/index.tsx";
import * as $start from "./routes/start.tsx";
import * as $GameDisplay from "./islands/GameDisplay.tsx";
import * as $GamesList from "./islands/GamesList.tsx";
import { type Manifest } from "$fresh/server.ts";

const manifest = {
routes: {
"./routes/_middleware.tsx": $0,
"./routes/api/events/game.ts": $1,
"./routes/api/events/games.ts": $2,
"./routes/api/place.ts": $3,
"./routes/auth/oauth2callback.ts": $4,
"./routes/auth/signin.ts": $5,
"./routes/auth/signout.ts": $6,
"./routes/game/[id].tsx": $7,
"./routes/index.tsx": $8,
"./routes/start.tsx": $9,
"./routes/_app.tsx": $_app,
"./routes/_middleware.tsx": $_middleware,
"./routes/api/events/game.ts": $api_events_game,
"./routes/api/events/games.ts": $api_events_games,
"./routes/api/place.ts": $api_place,
"./routes/auth/oauth2callback.ts": $auth_oauth2callback,
"./routes/auth/signin.ts": $auth_signin,
"./routes/auth/signout.ts": $auth_signout,
"./routes/game/[id].tsx": $game_id_,
"./routes/index.tsx": $index,
"./routes/start.tsx": $start,
},
islands: {
"./islands/GameDisplay.tsx": $$0,
"./islands/GamesList.tsx": $$1,
"./islands/GameDisplay.tsx": $GameDisplay,
"./islands/GamesList.tsx": $GamesList,
},
baseUrl: import.meta.url,
config,
};
} satisfies Manifest;

export default manifest;
17 changes: 0 additions & 17 deletions import_map.json

This file was deleted.

16 changes: 16 additions & 0 deletions routes/_app.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { PageProps } from "$fresh/server.ts";

export default function App({ Component }: PageProps) {
return (
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tic-tac-toe</title>
</head>
<body>
<Component />
</body>
</html>
);
}
102 changes: 37 additions & 65 deletions utils/db.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* This module implements the DB layer for the Tic Tac Toe game. It uses Deno's
* key-value store to store data, and uses BroadcastChannel to perform real-time
* synchronization between clients.
* key-value store to store data and perform real-time synchronization between clients.
*/

import { Game, OauthSession, User } from "./types.ts";
Expand Down Expand Up @@ -70,21 +69,9 @@ export async function setGame(game: Game, versionstamp?: string) {
.set(["games", game.id], game)
.set(["games_by_user", game.initiator.id, game.id], game)
.set(["games_by_user", game.opponent.id, game.id], game)
.set(["games_by_user_updated", game.initiator.id], true)
.set(["games_by_user_updated", game.opponent.id], true)
.commit();
if (res.ok) {
console.log("broadcasting game update", game.id, res.versionstamp);
const bc1 = new BroadcastChannel(`game/${game.id}`);
bc1.postMessage({ game, versionstamp: res!.versionstamp });
const bc2 = new BroadcastChannel(`games_by_user/${game.initiator.id}`);
bc2.postMessage({ game, versionstamp: res!.versionstamp });
const bc3 = new BroadcastChannel(`games_by_user/${game.opponent.id}`);
bc3.postMessage({ game, versionstamp: res!.versionstamp });
setTimeout(() => {
bc1.close();
bc2.close();
bc3.close();
}, 5);
}
return res.ok;
}

Expand Down Expand Up @@ -112,65 +99,50 @@ export function subscribeGame(
id: string,
cb: (game: Game) => void,
): () => void {
const bc = new BroadcastChannel(`game/${id}`);
let closed = false;
let lastVersionstamp = "";
getGameWithVersionstamp(id).then((res) => {
if (closed) return;
if (res) {
lastVersionstamp = res[1];
cb(res[0]);
const stream = kv.watch([["games", id]]);
const reader = stream.getReader();

(async () => {
while (true) {
const x = await reader.read();
if (x.done) {
console.log("subscribeGame: Subscription stream closed");
return;
}

const [game] = x.value!;
if (game.value) {
cb(game.value as Game);
}
}
});
bc.onmessage = (ev) => {
console.log(
"received game update",
id,
ev.data.versionstamp,
`(last: ${lastVersionstamp})`,
);
if (lastVersionstamp >= ev.data.versionstamp) return;
cb(ev.data.game);
};
})();

return () => {
closed = true;
bc.close();
reader.cancel();
};
}

export function subscribeGamesByPlayer(
userId: string,
cb: (list: Game[]) => void,
) {
const bc = new BroadcastChannel(`games_by_user/${userId}`);
let closed = false;
listGamesByPlayer(userId).then((list) => {
if (closed) return;
cb(list);
const lastVersionstamps = new Map<string, string>();
bc.onmessage = (e) => {
const { game, versionstamp } = e.data;
console.log(
"received games_by_user update",
game.id,
versionstamp,
`(last: ${lastVersionstamps.get(game.id)})`,
);
if ((lastVersionstamps.get(game.id) ?? "") >= versionstamp) return;
lastVersionstamps.set(game.id, versionstamp);
for (let i = 0; i < list.length; i++) {
if (list[i].id === game.id) {
list[i] = game;
cb(list);
return;
}
const stream = kv.watch([["games_by_user_updated", userId]]);
const reader = stream.getReader();

(async () => {
while (true) {
const x = await reader.read();
if (x.done) {
console.log("subscribeGamesByPlayer: Subscription stream closed");
return;
}
list.push(game);
cb(list);
};
});

const games = await listGamesByPlayer(userId);
cb(games);
}
})();

return () => {
closed = true;
bc.close();
reader.cancel();
};
}

0 comments on commit 2800ce2

Please sign in to comment.