├── README.md ├── client ├── App.tsx ├── Canvas.tsx ├── index.js └── index.tsx ├── info.txt ├── server ├── db.ts ├── index.html ├── index.ts └── run_express.ts └── shared └── types.ts /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Multiplayer Drawing App 3 | 4 | This is a multiplayer drawing app where strokes appear on everyone else's screens in realtime. Users can pick a name and color, and are saved to the database on login. 5 | 6 | ## Installation 7 | 8 | 1. Make sure you have Node.js and Bun installed. 9 | 2. Clone this repository. 10 | 3. Run `bun install` to install the dependencies. 11 | 12 | ## Running the App 13 | 14 | To start the app, run the following command: 15 | 16 | 17 | bun server/run.ts 18 | 19 | 20 | This will start the server on port 8000. Open your browser and navigate to `http://localhost:8000` to use the app. 21 | 22 | -------------------------------------------------------------------------------- /client/App.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useEffect, useState } from 'react'; 3 | import { io, Socket } from 'socket.io-client'; 4 | import { LoginRequest, LoginResponse, User, Stroke, DrawRequest } from '../shared/types'; 5 | import Canvas from './Canvas'; 6 | 7 | const socket: Socket = io(); 8 | 9 | function App() { 10 | const [user, setUser] = useState(null); 11 | const [strokes, setStrokes] = useState([]); 12 | 13 | useEffect(() => { 14 | socket.on('connect', () => { 15 | console.log('connected to server'); 16 | }); 17 | 18 | socket.on('stroke', (stroke: Stroke) => { 19 | setStrokes((prevStrokes) => [...prevStrokes, stroke]); 20 | }); 21 | 22 | socket.emit('getStrokes', (strokes: Stroke[]) => { 23 | setStrokes(strokes); 24 | }); 25 | 26 | return () => { 27 | socket.off('connect'); 28 | socket.off('stroke'); 29 | }; 30 | }, []); 31 | 32 | const handleLogin = (request: LoginRequest) => { 33 | socket.emit('login', request, (response: LoginResponse) => { 34 | setUser(response.user); 35 | }); 36 | }; 37 | 38 | const handleDraw = (points: DrawRequest['points']) => { 39 | if (!user) { 40 | return; 41 | } 42 | const request: DrawRequest = { 43 | userId: user.id, 44 | points, 45 | }; 46 | socket.emit('draw', request); 47 | setStrokes((prevStrokes) => [ 48 | ...prevStrokes, 49 | { 50 | id: Math.random().toString(), 51 | userId: user.id, 52 | points, 53 | color: user.color, 54 | }, 55 | ]); 56 | }; 57 | 58 | return ( 59 |
60 | {user ? ( 61 | 62 | ) : ( 63 | 64 | )} 65 |
66 | ); 67 | } 68 | 69 | interface LoginFormProps { 70 | onLogin: (request: LoginRequest) => void; 71 | } 72 | 73 | function LoginForm({ onLogin }: LoginFormProps) { 74 | const [name, setName] = useState(''); 75 | const [color, setColor] = useState('#000000'); 76 | 77 | const handleSubmit = (e: React.FormEvent) => { 78 | e.preventDefault(); 79 | onLogin({ name, color }); 80 | }; 81 | 82 | return ( 83 |
84 |
85 | 86 | setName(e.target.value)} 91 | required 92 | /> 93 |
94 |
95 | 96 | setColor(e.target.value)} 101 | required 102 | /> 103 |
104 | 105 |
106 | ); 107 | } 108 | 109 | export default App; 110 | -------------------------------------------------------------------------------- /client/Canvas.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { useRef, useEffect } from 'react'; 3 | import { Stroke, Point } from '../shared/types'; 4 | 5 | interface CanvasProps { 6 | strokes: Stroke[]; 7 | onDraw: (points: Point[]) => void; 8 | } 9 | 10 | function Canvas({ strokes, onDraw }: CanvasProps) { 11 | const canvasRef = useRef(null); 12 | const isDrawingRef = useRef(false); 13 | const prevPointRef = useRef(null); 14 | 15 | useEffect(() => { 16 | const canvas = canvasRef.current; 17 | if (!canvas) { 18 | return; 19 | } 20 | const ctx = canvas.getContext('2d'); 21 | if (!ctx) { 22 | return; 23 | } 24 | ctx.clearRect(0, 0, canvas.width, canvas.height); 25 | strokes.forEach((stroke) => { 26 | if (stroke.points.length > 1) { 27 | ctx.beginPath(); 28 | ctx.moveTo(stroke.points[0].x, stroke.points[0].y); 29 | for (let i = 1; i < stroke.points.length; i++) { 30 | ctx.lineTo(stroke.points[i].x, stroke.points[i].y); 31 | } 32 | ctx.strokeStyle = stroke.color; 33 | ctx.lineWidth = 2; 34 | ctx.lineCap = 'round'; 35 | ctx.stroke(); 36 | } 37 | }); 38 | }, [strokes]); 39 | 40 | const handlePointerDown = (e: React.PointerEvent) => { 41 | isDrawingRef.current = true; 42 | prevPointRef.current = { 43 | x: e.clientX, 44 | y: e.clientY, 45 | }; 46 | }; 47 | 48 | const handlePointerMove = (e: React.PointerEvent) => { 49 | if (!isDrawingRef.current || !prevPointRef.current) { 50 | return; 51 | } 52 | const point: Point = { 53 | x: e.clientX, 54 | y: e.clientY, 55 | }; 56 | onDraw([prevPointRef.current, point]); 57 | prevPointRef.current = point; 58 | }; 59 | 60 | const handlePointerUp = () => { 61 | isDrawingRef.current = false; 62 | prevPointRef.current = null; 63 | }; 64 | 65 | return ( 66 | 74 | ); 75 | } 76 | 77 | export default Canvas; 78 | -------------------------------------------------------------------------------- /client/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom/client'; 4 | import App from './App'; 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | 10 | ); 11 | 12 | -------------------------------------------------------------------------------- /info.txt: -------------------------------------------------------------------------------- 1 | Context: 2 | https://twitter.com/mayfer/status/1765385826496864290 -------------------------------------------------------------------------------- /server/db.ts: -------------------------------------------------------------------------------- 1 | 2 | import sqlite3 from 'sqlite3'; 3 | 4 | const db = new sqlite3.Database('db.sqlite'); 5 | 6 | export function initDb() { 7 | db.run(` 8 | CREATE TABLE IF NOT EXISTS users ( 9 | id TEXT PRIMARY KEY, 10 | name TEXT, 11 | color TEXT 12 | ) 13 | `); 14 | 15 | db.run(` 16 | CREATE TABLE IF NOT EXISTS strokes ( 17 | id TEXT PRIMARY KEY, 18 | userId TEXT, 19 | points TEXT, 20 | color TEXT, 21 | FOREIGN KEY (userId) REFERENCES users (id) 22 | ) 23 | `); 24 | } 25 | 26 | export function createUser(user: User): Promise { 27 | return new Promise((resolve, reject) => { 28 | db.run( 29 | 'INSERT INTO users (id, name, color) VALUES (?, ?, ?)', 30 | [user.id, user.name, user.color], 31 | (err) => { 32 | if (err) { 33 | reject(err); 34 | } else { 35 | resolve(); 36 | } 37 | } 38 | ); 39 | }); 40 | } 41 | 42 | export function getUser(userId: string): Promise { 43 | return new Promise((resolve, reject) => { 44 | db.get('SELECT * FROM users WHERE id = ?', [userId], (err, row) => { 45 | if (err) { 46 | reject(err); 47 | } else { 48 | resolve(row as User); 49 | } 50 | }); 51 | }); 52 | } 53 | 54 | export function createStroke(stroke: Stroke): Promise { 55 | return new Promise((resolve, reject) => { 56 | db.run( 57 | 'INSERT INTO strokes (id, userId, points, color) VALUES (?, ?, ?, ?)', 58 | [stroke.id, stroke.userId, JSON.stringify(stroke.points), stroke.color], 59 | (err) => { 60 | if (err) { 61 | reject(err); 62 | } else { 63 | resolve(); 64 | } 65 | } 66 | ); 67 | }); 68 | } 69 | 70 | export function getStrokes(): Promise { 71 | return new Promise((resolve, reject) => { 72 | db.all('SELECT * FROM strokes', (err, rows) => { 73 | if (err) { 74 | reject(err); 75 | } else { 76 | const strokes = rows.map((row) => ({ 77 | ...row, 78 | points: JSON.parse(row.points), 79 | })); 80 | resolve(strokes); 81 | } 82 | }); 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /server/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 |
10 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { app, io } from './run_express'; 3 | import { initDb, createUser, getUser, createStroke, getStrokes } from './db'; 4 | import { LoginRequest, LoginResponse, DrawRequest, User, Stroke } from '../shared/types'; 5 | import { v4 as uuidv4 } from 'uuid'; 6 | 7 | initDb(); 8 | 9 | io.on('connection', (socket) => { 10 | console.log('a user connected'); 11 | 12 | socket.on('login', async (req: LoginRequest, callback) => { 13 | const userId = uuidv4(); 14 | const user: User = { 15 | id: userId, 16 | name: req.name, 17 | color: req.color, 18 | }; 19 | await createUser(user); 20 | callback({ user }); 21 | }); 22 | 23 | socket.on('draw', async (req: DrawRequest) => { 24 | const user = await getUser(req.userId); 25 | if (!user) { 26 | return; 27 | } 28 | const stroke: Stroke = { 29 | id: uuidv4(), 30 | userId: req.userId, 31 | points: req.points, 32 | color: user.color, 33 | }; 34 | await createStroke(stroke); 35 | socket.broadcast.emit('stroke', stroke); 36 | }); 37 | 38 | socket.on('getStrokes', async (callback) => { 39 | const strokes = await getStrokes(); 40 | callback(strokes); 41 | }); 42 | 43 | socket.on('disconnect', () => { 44 | console.log('user disconnected'); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /server/run_express.ts: -------------------------------------------------------------------------------- 1 | 2 | import http from "http"; 3 | import { Server } from "socket.io"; 4 | import express from "express"; 5 | import cookieParser from "cookie-parser"; 6 | import path from "path"; 7 | 8 | export const app = express(); 9 | export const server = http.createServer(app); 10 | export const io = new Server(server); 11 | 12 | app.use(express.json()); 13 | app.use(cookieParser()); 14 | 15 | app.get('/', (req, res) => { 16 | res.sendFile(__dirname + '/index.html'); 17 | }); 18 | 19 | // serve static files 20 | app.get('/client/:file', (req, res) => { 21 | // res.sendFile(__dirname + '../client/' + req.params.file); 22 | const filepath = path.join(__dirname, '../client', req.params.file); 23 | try { 24 | res.sendFile(filepath); 25 | } catch (e) { 26 | res.send(""); 27 | } 28 | }) 29 | 30 | const port = process.env.PORT || 8000; 31 | server.listen(port, () => { 32 | console.log('Server is running on port ' + port); 33 | }); 34 | -------------------------------------------------------------------------------- /shared/types.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface User { 3 | id: string; 4 | name: string; 5 | color: string; 6 | } 7 | 8 | export interface Stroke { 9 | id: string; 10 | userId: string; 11 | points: Point[]; 12 | color: string; 13 | } 14 | 15 | export interface Point { 16 | x: number; 17 | y: number; 18 | } 19 | 20 | export interface LoginRequest { 21 | name: string; 22 | color: string; 23 | } 24 | 25 | export interface LoginResponse { 26 | user: User; 27 | } 28 | 29 | export interface DrawRequest { 30 | userId: string; 31 | points: Point[]; 32 | } 33 | --------------------------------------------------------------------------------