├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── components ├── Button.tsx ├── Header.tsx ├── User.tsx └── animations.ts ├── deno.json ├── deno.lock ├── dev.ts ├── fresh.gen.ts ├── islands ├── GameDisplay.tsx └── GamesList.tsx ├── main.ts ├── routes ├── _app.tsx ├── _middleware.tsx ├── api │ ├── events │ │ ├── game.ts │ │ └── games.ts │ └── place.ts ├── auth │ ├── oauth2callback.ts │ ├── signin.ts │ └── signout.ts ├── game │ └── [id].tsx ├── index.tsx └── start.tsx ├── static ├── favicon.ico ├── logo.svg └── screenshot.png ├── twind.config.ts └── utils ├── db.ts ├── game.ts ├── github.ts ├── hooks.ts ├── oauth.ts └── types.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | # Fresh build directory 3 | _fresh/ 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno", 4 | "sastan.twind-intellisense" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": true, 5 | "editor.defaultFormatter": "denoland.vscode-deno" 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Deno 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tic-Tac-Toe 2 | 3 | This is a global, real-time multiplayer Tic-Tac-Toe game written in Deno. It 4 | persists game states in a Deno KV store, and synchronizes game state between 5 | clients using the `watch()` feature of Deno KV. 6 | 7 | ## Features 8 | 9 | - Real-time multiplayer game 10 | - Persistent game state and coordination using Deno KV 11 | - Uses GitHub OAuth for authentication 12 | 13 | This project is hosted on Deno Deploy: 14 | 15 | - Served from 35 edge locations around the world 16 | - Scales automatically 17 | - Data is a globally distributed Deno KV store with no setup required 18 | - Code is deployed automatically when pushed to GitHub 19 | - Automatic HTTPS (even for custom domains) 20 | - Free for most hobby use cases 21 | 22 | ## Example 23 | 24 | You can try out the game at https://tic-tac-toe-game.deno.dev 25 | 26 | ![Screenshot](./static/screenshot.png) 27 | 28 | ## Development 29 | 30 | To develop locally, you must create a GitHub OAuth application and set the 31 | following environment variables in a `.env` file: 32 | 33 | ``` 34 | GITHUB_CLIENT_ID=... 35 | GITHUB_CLIENT_SECRET=... 36 | ``` 37 | 38 | You can create a GitHub OAuth application at 39 | https://github.com/settings/applications/new. Set the callback URL to 40 | `http://localhost:8000/auth/oauth2callback`. 41 | 42 | You can then start the local development server: 43 | 44 | ``` 45 | deno task start 46 | ``` 47 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "preact"; 2 | import { css, keyframes } from "twind/css"; 3 | import { apply, tw } from "twind"; 4 | 5 | const buttonClasses = 6 | "px-4 py-2 text-sm font-semibold text-white bg-blue-500 rounded"; 7 | 8 | export function Button(props: JSX.HTMLAttributes) { 9 | return 73 | 74 |

75 | Or, challenge one of these other users: 76 |

77 | 80 | 81 | ); 82 | } 83 | 84 | /** A list item to display a user. Includes a button to challenge the user to a 85 | * game. Displays name, handle, and avatar. */ 86 | function UserListItem(props: { user: User }) { 87 | const startPath = `/start?opponent=${props.user.login}`; 88 | return ( 89 |
  • 90 | {props.user.login} 95 | 96 |
    97 | 104 | Start Game 105 | 106 |
    107 |
  • 108 | ); 109 | } 110 | 111 | function SignedOut() { 112 | return ( 113 | <> 114 |

    115 | Welcome to the Deno Tic-Tac-Toe game! You can log in with your GitHub 116 | account below to challenge others to a game of Tic-Tac-Toe. 117 |

    118 |

    119 | 120 | Log in with GitHub 121 | 122 |

    123 | 124 | ); 125 | } 126 | -------------------------------------------------------------------------------- /routes/start.tsx: -------------------------------------------------------------------------------- 1 | import { Handlers } from "$fresh/server.ts"; 2 | import { getUserByLogin, getUserBySession, setGame } from "🛠️/db.ts"; 3 | import { Game, State } from "🛠️/types.ts"; 4 | 5 | export const handler: Handlers = { 6 | async POST(req, ctx) { 7 | if (!ctx.state.session) { 8 | return new Response("Not logged in", { status: 401 }); 9 | } 10 | 11 | let opponent; 12 | const formData = await req.formData(); 13 | opponent = formData.get("opponent"); 14 | 15 | if (typeof opponent !== "string") { 16 | const url = new URL(req.url); 17 | opponent = url.searchParams.get("opponent"); 18 | } 19 | 20 | if (typeof opponent !== "string") { 21 | return new Response("Missing or invalid opponent", { status: 400 }); 22 | } 23 | 24 | if (opponent.startsWith("@")) { 25 | opponent = opponent.slice(1); 26 | } 27 | const [initiatorUser, opponentUser] = await Promise.all([ 28 | getUserBySession(ctx.state.session), 29 | getUserByLogin(opponent), 30 | ]); 31 | if (!initiatorUser) return new Response("Not logged in", { status: 401 }); 32 | if (!opponentUser) { 33 | return new Response( 34 | "Opponent user has not signed up yet. Ask them to sign in to TicTacToe to play against you.", 35 | { status: 400 }, 36 | ); 37 | } 38 | if (initiatorUser.id === opponentUser.id) { 39 | return new Response("Cannot play against yourself", { status: 400 }); 40 | } 41 | 42 | const game: Game = { 43 | id: Math.random().toString(36).slice(2), 44 | initiator: initiatorUser, 45 | opponent: opponentUser, 46 | grid: [null, null, null, null, null, null, null, null, null], 47 | startedAt: new Date(), 48 | lastMoveAt: new Date(), 49 | }; 50 | await setGame(game); 51 | 52 | return new Response(null, { 53 | status: 302, 54 | headers: { 55 | "Location": `/game/${game.id}`, 56 | }, 57 | }); 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denoland/tic-tac-toe/2800ce2a5c3488e9469baa4f8a81104e2e364e07/static/favicon.ico -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/denoland/tic-tac-toe/2800ce2a5c3488e9469baa4f8a81104e2e364e07/static/screenshot.png -------------------------------------------------------------------------------- /twind.config.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "$fresh/plugins/twind.ts"; 2 | 3 | export default { 4 | selfURL: import.meta.url, 5 | } as Options; 6 | -------------------------------------------------------------------------------- /utils/db.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module implements the DB layer for the Tic Tac Toe game. It uses Deno's 3 | * key-value store to store data and perform real-time synchronization between clients. 4 | */ 5 | 6 | import { Game, OauthSession, User } from "./types.ts"; 7 | 8 | const kv = await Deno.openKv(); 9 | 10 | export async function getAndDeleteOauthSession( 11 | session: string, 12 | ): Promise { 13 | const res = await kv.get(["oauth_sessions", session]); 14 | if (res.versionstamp === null) return null; 15 | await kv.delete(["oauth_sessions", session]); 16 | return res.value; 17 | } 18 | 19 | export async function setOauthSession(session: string, value: OauthSession) { 20 | await kv.set(["oauth_sessions", session], value); 21 | } 22 | 23 | export async function setUserWithSession(user: User, session: string) { 24 | await kv.atomic() 25 | .set(["users", user.id], user) 26 | .set(["users_by_login", user.login], user) 27 | .set(["users_by_session", session], user) 28 | .set(["users_by_last_signin", new Date().toISOString(), user.id], user) 29 | .commit(); 30 | } 31 | 32 | export async function getUserBySession(session: string) { 33 | const res = await kv.get(["users_by_session", session]); 34 | return res.value; 35 | } 36 | 37 | export async function getUserById(id: string) { 38 | const res = await kv.get(["users", id]); 39 | return res.value; 40 | } 41 | 42 | export async function getUserByLogin(login: string) { 43 | const res = await kv.get(["users_by_login", login]); 44 | return res.value; 45 | } 46 | 47 | export async function deleteSession(session: string) { 48 | await kv.delete(["users_by_session", session]); 49 | } 50 | 51 | export async function listRecentlySignedInUsers(): Promise { 52 | const users = []; 53 | const iter = kv.list({ prefix: ["users_by_last_signin"] }, { 54 | limit: 10, 55 | reverse: true, 56 | }); 57 | for await (const { value } of iter) { 58 | users.push(value); 59 | } 60 | return users; 61 | } 62 | 63 | export async function setGame(game: Game, versionstamp?: string) { 64 | const ao = kv.atomic(); 65 | if (versionstamp) { 66 | ao.check({ key: ["games", game.id], versionstamp }); 67 | } 68 | const res = await ao 69 | .set(["games", game.id], game) 70 | .set(["games_by_user", game.initiator.id, game.id], game) 71 | .set(["games_by_user", game.opponent.id, game.id], game) 72 | .set(["games_by_user_updated", game.initiator.id], true) 73 | .set(["games_by_user_updated", game.opponent.id], true) 74 | .commit(); 75 | return res.ok; 76 | } 77 | 78 | export async function listGamesByPlayer(userId: string): Promise { 79 | const games: Game[] = []; 80 | const iter = kv.list({ prefix: ["games_by_user", userId] }); 81 | for await (const { value } of iter) { 82 | games.push(value); 83 | } 84 | return games; 85 | } 86 | 87 | export async function getGame(id: string) { 88 | const res = await kv.get(["games", id]); 89 | return res.value; 90 | } 91 | 92 | export async function getGameWithVersionstamp(id: string) { 93 | const res = await kv.get(["games", id]); 94 | if (res.versionstamp === null) return null; 95 | return [res.value, res.versionstamp] as const; 96 | } 97 | 98 | export function subscribeGame( 99 | id: string, 100 | cb: (game: Game) => void, 101 | ): () => void { 102 | const stream = kv.watch([["games", id]]); 103 | const reader = stream.getReader(); 104 | 105 | (async () => { 106 | while (true) { 107 | const x = await reader.read(); 108 | if (x.done) { 109 | console.log("subscribeGame: Subscription stream closed"); 110 | return; 111 | } 112 | 113 | const [game] = x.value!; 114 | if (game.value) { 115 | cb(game.value as Game); 116 | } 117 | } 118 | })(); 119 | 120 | return () => { 121 | reader.cancel(); 122 | }; 123 | } 124 | 125 | export function subscribeGamesByPlayer( 126 | userId: string, 127 | cb: (list: Game[]) => void, 128 | ) { 129 | const stream = kv.watch([["games_by_user_updated", userId]]); 130 | const reader = stream.getReader(); 131 | 132 | (async () => { 133 | while (true) { 134 | const x = await reader.read(); 135 | if (x.done) { 136 | console.log("subscribeGamesByPlayer: Subscription stream closed"); 137 | return; 138 | } 139 | 140 | const games = await listGamesByPlayer(userId); 141 | cb(games); 142 | } 143 | })(); 144 | 145 | return () => { 146 | reader.cancel(); 147 | }; 148 | } 149 | -------------------------------------------------------------------------------- /utils/game.ts: -------------------------------------------------------------------------------- 1 | import { Game } from "./types.ts"; 2 | 3 | export interface GameStateInProgress { 4 | state: "in_progress"; 5 | turn: string; 6 | } 7 | 8 | export interface GameStateTie { 9 | state: "tie"; 10 | } 11 | 12 | export interface GameStateWin { 13 | state: "win"; 14 | winner: string; 15 | } 16 | 17 | export type GameState = GameStateInProgress | GameStateTie | GameStateWin; 18 | 19 | const WIN_LINES = [ 20 | [0, 1, 2], 21 | [3, 4, 5], 22 | [6, 7, 8], 23 | [0, 3, 6], 24 | [1, 4, 7], 25 | [2, 5, 8], 26 | [0, 4, 8], 27 | [2, 4, 6], 28 | ]; 29 | 30 | // GameGrid is a 9-element array of user id strings or nulls 31 | export function analyzeGame(game: Game): GameState { 32 | const { grid, initiator: initiatorUser, opponent: opponentUser } = game; 33 | 34 | // Determine whose turn it is next 35 | let initiator = 0; 36 | let opponent = 0; 37 | for (const cell of grid) { 38 | if (cell === initiatorUser.id) { 39 | initiator++; 40 | } else if (cell === opponentUser.id) { 41 | opponent++; 42 | } 43 | } 44 | const turn = initiator > opponent ? opponentUser.id : initiatorUser.id; 45 | 46 | // Check for a win, or a tie situation. Ties can occur when all cells are 47 | // filled, or when all winning lines contain a mix of both players' ids. 48 | let allFilled = true; 49 | let allMixed = true; 50 | for (const [a, b, c] of WIN_LINES) { 51 | const cells = new Set([grid[a], grid[b], grid[c]]); 52 | if (cells.size === 1 && cells.has(initiatorUser.id)) { 53 | return { state: "win", winner: initiatorUser.id }; 54 | } 55 | if (cells.size === 1 && cells.has(opponentUser.id)) { 56 | return { state: "win", winner: opponentUser.id }; 57 | } 58 | if (cells.has(null)) { 59 | allFilled = false; 60 | } 61 | cells.delete(null); 62 | if (cells.size !== 2) { 63 | allMixed = false; 64 | } 65 | } 66 | if (allFilled || allMixed) { 67 | return { state: "tie" }; 68 | } 69 | return { state: "in_progress", turn }; 70 | } 71 | -------------------------------------------------------------------------------- /utils/github.ts: -------------------------------------------------------------------------------- 1 | interface GitHubUser { 2 | id: number; 3 | login: string; 4 | name: string; 5 | avatar_url: string; 6 | } 7 | 8 | export async function getAuthenticatedUser(token: string): Promise { 9 | const resp = await fetch("https://api.github.com/user", { 10 | headers: { 11 | Authorization: `token ${token}`, 12 | }, 13 | }); 14 | if (!resp.ok) { 15 | throw new Error("Failed to fetch user"); 16 | } 17 | return await resp.json(); 18 | } 19 | -------------------------------------------------------------------------------- /utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "preact/hooks"; 2 | 3 | /** 4 | * The same as useEffect, but also cleans up when the page is hidden and re-runs 5 | * the effect when the page is shown again. 6 | */ 7 | export function useDataSubscription(cb: () => () => void, arr: unknown[]) { 8 | useEffect(() => { 9 | let cleanup: (() => void) | undefined = cb(); 10 | function pageHideHandler() { 11 | cleanup?.(); 12 | cleanup = undefined; 13 | } 14 | self.addEventListener("pagehide", pageHideHandler); 15 | function pageShowHandler() { 16 | cleanup?.(); 17 | cleanup = cb(); 18 | } 19 | self.addEventListener("pageshow", pageShowHandler); 20 | return () => { 21 | cleanup?.(); 22 | cleanup = undefined; 23 | self.removeEventListener("pagehide", pageHideHandler); 24 | self.removeEventListener("pageshow", pageShowHandler); 25 | }; 26 | }, arr); 27 | } 28 | -------------------------------------------------------------------------------- /utils/oauth.ts: -------------------------------------------------------------------------------- 1 | import "$std/dotenv/load.ts"; 2 | import { OAuth2Client } from "https://deno.land/x/oauth2_client@v1.0.0/mod.ts"; 3 | 4 | export const oauth2Client = new OAuth2Client({ 5 | clientId: Deno.env.get("GITHUB_CLIENT_ID")!, 6 | clientSecret: Deno.env.get("GITHUB_CLIENT_SECRET")!, 7 | authorizationEndpointUri: "https://github.com/login/oauth/authorize", 8 | tokenUri: "https://github.com/login/oauth/access_token", 9 | defaults: { 10 | scope: "read:user", 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /utils/types.ts: -------------------------------------------------------------------------------- 1 | export interface State { 2 | session: string | undefined; 3 | } 4 | 5 | export interface User { 6 | id: string; 7 | login: string; 8 | name: string; 9 | avatarUrl: string; 10 | } 11 | 12 | export interface OauthSession { 13 | state: string; 14 | codeVerifier: string; 15 | } 16 | 17 | export type GameGrid = [ 18 | string | null, 19 | string | null, 20 | string | null, 21 | string | null, 22 | string | null, 23 | string | null, 24 | string | null, 25 | string | null, 26 | string | null, 27 | ]; 28 | 29 | export interface Game { 30 | id: string; 31 | initiator: User; 32 | opponent: User; 33 | grid: GameGrid; 34 | startedAt: Date; 35 | lastMoveAt: Date; 36 | } 37 | --------------------------------------------------------------------------------