├── .node-version ├── static ├── version ├── favicon.ico ├── robots.txt ├── apple-touch-icon.png └── favicon.svg ├── .github ├── FUNDING.yml └── workflows │ ├── release.yaml │ └── tests.yaml ├── .npmrc ├── netlify.toml ├── CODE-OF-CONDUCT.md ├── postcss.config.js ├── src ├── routes │ ├── +layout.svelte │ ├── +layout.ts │ ├── (iframed) │ │ ├── +layout.svelte │ │ ├── settings │ │ │ └── +page.svelte │ │ └── +page.svelte │ └── examples │ │ └── iframes │ │ └── +page.svelte ├── sanity.test.ts ├── lib │ ├── highlight.ts │ ├── components │ │ ├── SvgHazard.svelte │ │ ├── SvgFood.svelte │ │ ├── SvgSnake.svelte │ │ ├── TooltipTemplateSettings.svelte │ │ ├── SvgGrid.svelte │ │ ├── Scrubber.svelte │ │ ├── TooltipTemplateHotkeys.svelte │ │ ├── PlaybackControls.svelte │ │ ├── SvgSnakeTail.svelte │ │ ├── Gameboard.svelte │ │ ├── SvgSnakeHead.svelte │ │ ├── Scoreboard.svelte │ │ └── SvgSnakeBody.svelte │ ├── actions │ │ ├── tooltip.ts │ │ ├── resize.ts │ │ └── keybind.ts │ ├── theme.ts │ ├── playback │ │ ├── animation.ts │ │ ├── messages.ts │ │ ├── types.ts │ │ ├── engine.ts │ │ └── stores.ts │ ├── geometry.ts │ ├── customizations.ts │ ├── settings │ │ ├── helpers.ts │ │ └── stores.ts │ └── svg.ts ├── app.d.ts ├── styles.css └── app.html ├── .eslintignore ├── .prettierignore ├── .prettierrc ├── tests └── test.ts ├── tailwind.config.js ├── .gitignore ├── playwright.config.ts ├── vite.config.ts ├── CONTRIBUTING.md ├── tsconfig.json ├── .devcontainer └── devcontainer.json ├── .eslintrc ├── svelte.config.js ├── package.json ├── README.md └── LICENSE /.node-version: -------------------------------------------------------------------------------- 1 | 18.17.1 2 | -------------------------------------------------------------------------------- /static/version: -------------------------------------------------------------------------------- 1 | 0.0.0 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ['BattlesnakeOfficial'] 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | publish = "build" 4 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BattlesnakeOfficial/board/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Battlesnake Code of Conduct 2 | 3 | Please see https://docs.battlesnake.com/policies/conduct 4 | -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BattlesnakeOfficial/board/HEAD/static/apple-touch-icon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 |
8 | -------------------------------------------------------------------------------- /src/sanity.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest"; 2 | 3 | test("sanity", () => { 4 | expect(true).toBeTruthy(); 5 | }); 6 | -------------------------------------------------------------------------------- /src/lib/highlight.ts: -------------------------------------------------------------------------------- 1 | import { writable } from "svelte/store"; 2 | 3 | export const highlightedSnakeID = writable(null); 4 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | // We're a strictly static site, disable SSR site-wide and force all pages to prerender. 2 | // See: https://kit.svelte.dev/docs/adapter-static 3 | export const prerender = true; 4 | export const ssr = false; 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | /.svelte-kit 5 | /build 6 | /package 7 | 8 | .env 9 | .env.* 10 | !.env.example 11 | 12 | # Ignore files for PNPM, NPM and YARN 13 | pnpm-lock.yaml 14 | package-lock.json 15 | yarn.lock 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "none", 4 | "useTabs": false, 5 | "plugins": ["prettier-plugin-svelte"], 6 | "pluginSearchDirs": ["."], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /tests/test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@playwright/test"; 2 | 3 | test("about page has expected h1", async ({ page }) => { 4 | await page.goto("/about"); 5 | await expect(page.getByRole("heading", { name: "About this app" })).toBeVisible(); 6 | }); 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./src/**/*.{html,js,svelte,ts}"], 4 | plugins: [require("@tailwindcss/forms")], 5 | darkMode: "class", 6 | theme: { 7 | extend: {} 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # General 2 | .DS_Store 3 | .vscode 4 | 5 | # Node 6 | node_modules 7 | 8 | # SvelteKit 9 | .output 10 | .svelte-kit 11 | /build 12 | /package 13 | 14 | .env 15 | .env.* 16 | !.env.example 17 | 18 | vite.config.js.timestamp-* 19 | vite.config.ts.timestamp-* 20 | 21 | # Netlify 22 | .netlify 23 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from "@playwright/test"; 2 | 3 | const config: PlaywrightTestConfig = { 4 | webServer: { 5 | command: "npm run build && npm run preview", 6 | port: 4173 7 | }, 8 | testDir: "tests", 9 | testMatch: /(.+\.)?(test|spec)\.[jt]s/ 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import { sveltekit } from "@sveltejs/kit/vite"; 3 | 4 | import Icons from "unplugin-icons/vite"; 5 | 6 | export default defineConfig({ 7 | plugins: [sveltekit(), Icons({ compiler: "svelte" })], 8 | test: { 9 | include: ["src/**/*.{test,spec}.{js,ts}"] 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /static/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | 4 | import "unplugin-icons/types/svelte"; 5 | 6 | declare global { 7 | namespace App { 8 | // interface Error {} 9 | // interface Locals {} 10 | // interface PageData {} 11 | // interface Platform {} 12 | } 13 | } 14 | 15 | export {}; 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We welcome contributions from the community! 4 | 5 | Please see https://docs.battlesnake.com/community/contributing 6 | 7 | We track all issues/discussions for all our open source repos at https://github.com/BattlesnakeOfficial/feedback/discussions 8 | Any item tagged with "flag/help-wanted ✋" is a great place to start, as it highlights any issues where we'd love to get some help! 9 | -------------------------------------------------------------------------------- /src/routes/(iframed)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 9 |
10 | 11 |
12 | -------------------------------------------------------------------------------- /src/lib/components/SvgHazard.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/actions/tooltip.ts: -------------------------------------------------------------------------------- 1 | import tippy from "tippy.js"; 2 | 3 | type Options = { 4 | templateId: string; 5 | tippyProps: object; 6 | }; 7 | 8 | export function tooltip(node: HTMLElement, options: Options) { 9 | const props = { 10 | ...options.tippyProps, 11 | allowHTML: true, 12 | content: document.getElementById(options.templateId)?.innerHTML.slice() 13 | }; 14 | 15 | const tip = tippy(node, props); 16 | 17 | return { 18 | destroy: () => tip.destroy() 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "lib": [ 13 | // at() support 14 | "ES2022", 15 | 16 | // svelte-kit libs 17 | "esnext", 18 | "DOM", 19 | "DOM.Iterable" 20 | ] 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/components/SvgFood.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/lib/actions/resize.ts: -------------------------------------------------------------------------------- 1 | type Options = { 2 | f: (width: number, height: number) => void; 3 | }; 4 | 5 | export function resize(node: HTMLElement, options: Options) { 6 | const resizeObserver = new ResizeObserver((entries) => { 7 | for (const entry of entries) { 8 | const w = entry.contentBoxSize[0].inlineSize; 9 | const h = entry.contentBoxSize[0].blockSize; 10 | options.f(w, h); 11 | } 12 | }); 13 | resizeObserver.observe(node); 14 | 15 | return { 16 | destroy() { 17 | resizeObserver.unobserve(node); 18 | } 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Lilita+One&family=Poppins:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&display=swap"); 2 | @import "tippy.js/dist/tippy.css"; 3 | 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | html { 9 | font-family: "Poppins", sans-serif; 10 | @apply bg-white text-neutral-700; 11 | } 12 | 13 | html.dark, 14 | html.dark select, 15 | html.dark input[type="checkbox"], 16 | html.dark input[type="checkbox"]:hover, 17 | html.dark input[type="checkbox"]:focus { 18 | @apply text-white; 19 | background-color: #0f0b19; 20 | } 21 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | %sveltekit.head% 14 | 15 | 16 | 17 |
%sveltekit.body%
18 | 19 | 20 | -------------------------------------------------------------------------------- /src/lib/components/SvgSnake.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/theme.ts: -------------------------------------------------------------------------------- 1 | import { browser } from "$app/environment"; 2 | 3 | import { Theme } from "$lib/settings/stores"; 4 | 5 | export function setTheme(theme: Theme) { 6 | if (browser) { 7 | if (theme == Theme.DARK) { 8 | document.documentElement.classList.add("dark"); 9 | } else if (theme == Theme.LIGHT) { 10 | document.documentElement.classList.remove("dark"); 11 | } else if (theme == Theme.SYSTEM) { 12 | if (window.matchMedia("(prefers-color-scheme: dark)").matches) { 13 | document.documentElement.classList.add("dark"); 14 | } else { 15 | document.documentElement.classList.remove("dark"); 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/components/TooltipTemplateSettings.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 21 | -------------------------------------------------------------------------------- /src/lib/actions/keybind.ts: -------------------------------------------------------------------------------- 1 | import Mousestrap from "mousetrap"; 2 | 3 | type Options = { 4 | keys: string[]; 5 | f: () => void; 6 | }; 7 | 8 | export function keybind(node: HTMLElement, options: Options) { 9 | console.debug("[keybind] binding:", options.keys); 10 | 11 | const mousetrap = new Mousestrap(document.documentElement); 12 | mousetrap.bind(options.keys, () => { 13 | console.debug("[keybind] handling:", options.keys); 14 | options.f(); 15 | 16 | // Always prevent default behavior 17 | return false; 18 | }); 19 | 20 | return { 21 | destroy() { 22 | console.debug("[keybind] destorying:", options.keys); 23 | mousetrap.reset(); 24 | } 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "board.battlesnake.local", 3 | "image": "mcr.microsoft.com/devcontainers/javascript-node:18", 4 | "postCreateCommand": "npm install", 5 | "forwardPorts": [5173], 6 | "customizations": { 7 | "vscode": { 8 | "extensions": ["svelte.svelte-vscode", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint"], 9 | "settings": { 10 | "editor.tabSize": 2, 11 | "editor.formatOnSave": true, 12 | "editor.defaultFormatter": "esbenp.prettier-vscode", 13 | "eslint.validate": ["javascript", "svelte", "typescript"], 14 | "prettier.requireConfig": true, 15 | "svelte.enable-ts-plugin": true 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/lib/playback/animation.ts: -------------------------------------------------------------------------------- 1 | let activeInterval: undefined | ReturnType; 2 | 3 | export function startPlayback(fps: number, callback: () => void) { 4 | // Do nothing if playback is active 5 | if (activeInterval) { 6 | return; 7 | } 8 | 9 | console.debug(`[playback] starting playback at ${fps} fps`); 10 | 11 | // Play first frame immediately 12 | callback(); 13 | 14 | // Set interval for future frames 15 | const delayMS = 1000 / Math.ceil(fps); 16 | activeInterval = setInterval(() => { 17 | callback(); 18 | }, delayMS); 19 | } 20 | 21 | export function stopPlayback(): void { 22 | if (activeInterval) { 23 | clearInterval(activeInterval); 24 | activeInterval = undefined; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:svelte/recommended", 7 | "prettier" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "plugins": ["@typescript-eslint"], 11 | "parserOptions": { 12 | "sourceType": "module", 13 | "ecmaVersion": 2020, 14 | "extraFileExtensions": [".svelte"] 15 | }, 16 | "env": { 17 | "browser": true, 18 | "es2017": true, 19 | "node": true 20 | }, 21 | "overrides": [ 22 | { 23 | "files": ["*.svelte"], 24 | "parser": "svelte-eslint-parser", 25 | "parserOptions": { 26 | "parser": "@typescript-eslint/parser" 27 | } 28 | } 29 | ], 30 | "rules": { 31 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | branches: [main] 7 | 8 | jobs: 9 | tests: 10 | name: Tests 11 | uses: ./.github/workflows/tests.yaml 12 | 13 | deploy: 14 | name: netlify deploy 15 | needs: [tests] 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - run: echo ${{ github.event.release.tag_name }} > ./static/version 20 | - run: npm ci 21 | - run: npm install -g netlify-cli 22 | - run: netlify build 23 | env: 24 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 25 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 26 | - run: netlify deploy --prod --message ${{ github.event.release.tag_name }} 27 | env: 28 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 29 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 30 | -------------------------------------------------------------------------------- /src/lib/geometry.ts: -------------------------------------------------------------------------------- 1 | import type { Point } from "./playback/types"; 2 | 3 | export function isEqualPoint(p1?: Point, p2?: Point): boolean { 4 | if (p1 == undefined || p2 == undefined) { 5 | return false; 6 | } 7 | 8 | return p1.x == p2.x && p1.y == p2.y; 9 | } 10 | 11 | export function isAdjacentPoint(p1: Point, p2: Point): boolean { 12 | return calcManhattan(p1, p2) == 1; 13 | } 14 | 15 | export function calcManhattan(p1: Point, p2: Point): number { 16 | return Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y); 17 | } 18 | 19 | export function calcSourceWrapPosition(src: Point, dst: Point): Point { 20 | return { 21 | x: src.x - Math.sign(dst.x - src.x), 22 | y: src.y - Math.sign(dst.y - src.y) 23 | }; 24 | } 25 | 26 | export function calcDestinationWrapPosition(src: Point, dst: Point): Point { 27 | return { 28 | x: dst.x + Math.sign(dst.x - src.x), 29 | y: dst.y + Math.sign(dst.y - src.y) 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/customizations.ts: -------------------------------------------------------------------------------- 1 | const mediaCache: { [key: string]: string } = {}; 2 | 3 | export async function fetchCustomizationSvgDef(type: string, name: string) { 4 | const mediaPath = `snakes/${type}s/${name}.svg`; 5 | 6 | if (!(mediaPath in mediaCache)) { 7 | mediaCache[mediaPath] = await fetch(`https://media.battlesnake.com/${mediaPath}`) 8 | .then((response) => response.text()) 9 | .then((textSVG) => { 10 | const tempElememt = document.createElement("template"); 11 | tempElememt.innerHTML = textSVG.trim(); 12 | console.debug(`[customizations] loaded svg definition for ${mediaPath}`); 13 | 14 | if (tempElememt.content.firstChild === null) { 15 | console.debug("[customizations] error loading customization, no elements found"); 16 | return ""; 17 | } 18 | 19 | const child = tempElememt.content.firstChild; 20 | return child.innerHTML; 21 | }); 22 | } 23 | return mediaCache[mediaPath]; 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: # Branch pushes only, not tags 5 | branches: 6 | - "**" 7 | workflow_call: # Allow other workflows to call this one 8 | 9 | jobs: 10 | lint: 11 | name: npm run lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - run: npm ci 16 | - run: npm run lint 17 | 18 | check: 19 | name: npm run check 20 | needs: [lint] 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v3 24 | - run: npm ci 25 | - run: npm run check 26 | 27 | unit: 28 | name: npm run test:unit 29 | needs: [check] 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v3 33 | - run: npm ci 34 | - run: npm run test:unit 35 | 36 | build: 37 | name: npm run build 38 | needs: [check] 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v3 42 | - run: npm ci 43 | - run: npm run build 44 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 2 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 3 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 4 | // import adapter from '@sveltejs/adapter-auto'; 5 | import adapter from "@sveltejs/adapter-static"; 6 | 7 | import { vitePreprocess } from "@sveltejs/kit/vite"; 8 | 9 | /** @type {import('@sveltejs/kit').Config} */ 10 | const config = { 11 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 12 | // for more information about preprocessors 13 | preprocess: vitePreprocess(), 14 | kit: { 15 | adapter: adapter({ 16 | // These are the defaults, see https://kit.svelte.dev/docs/adapter-static 17 | pages: "build", 18 | assets: "build", 19 | fallback: undefined, 20 | precompress: false, 21 | strict: true 22 | }) 23 | } 24 | }; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /src/lib/playback/messages.ts: -------------------------------------------------------------------------------- 1 | import { browser } from "$app/environment"; 2 | 3 | import { playbackState } from "./stores"; 4 | import type { PlaybackState } from "./types"; 5 | 6 | enum GameEvent { 7 | // Basic display messages 8 | RESIZE = "RESIZE", 9 | TURN = "TURN", 10 | GAME_OVER = "GAME_OVER" 11 | 12 | // Could do eliminations, food spawns, hazard damage, etc etc etc. 13 | } 14 | 15 | type Message = { 16 | event: GameEvent; 17 | data: object; 18 | }; 19 | 20 | function postMessageToParent(message: Message) { 21 | if (browser) { 22 | try { 23 | window.parent.postMessage(message, "*"); 24 | } catch (e) { 25 | console.error(e); 26 | } 27 | } 28 | } 29 | 30 | export function sendResizeMessage(width: number, height: number) { 31 | postMessageToParent({ 32 | event: GameEvent.RESIZE, 33 | data: { width, height } 34 | }); 35 | } 36 | 37 | export function initWindowMessages() { 38 | playbackState.subscribe((state: PlaybackState | null) => { 39 | if (state) { 40 | postMessageToParent({ 41 | event: GameEvent.TURN, 42 | data: { 43 | turn: state.frame.turn 44 | } 45 | }); 46 | if (state.frame.isFinalFrame) { 47 | postMessageToParent({ 48 | event: GameEvent.GAME_OVER, 49 | data: {} 50 | }); 51 | } 52 | } 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/settings/helpers.ts: -------------------------------------------------------------------------------- 1 | import { browser } from "$app/environment"; 2 | 3 | export function fromLocalStorage(key: string, defaultValue: boolean | number | string) { 4 | if (browser) { 5 | const val = localStorage.getItem(`setting.${key}`); 6 | if (val) { 7 | return JSON.parse(val); 8 | } 9 | } 10 | return defaultValue; 11 | } 12 | 13 | export function toLocalStorage(key: string, value: boolean | number | string) { 14 | if (browser) { 15 | localStorage.setItem(`setting.${key}`, JSON.stringify(value)); 16 | } 17 | } 18 | 19 | export function getBoolFromURL(url: URL, key: string, defaultValue: boolean): boolean { 20 | const val = url.searchParams.get(key); 21 | if (val) { 22 | if (val === "true") return true; 23 | if (val === "false") return false; 24 | } 25 | return defaultValue; 26 | } 27 | 28 | export function getIntFromURL(url: URL, key: string, defaultValue: number): number { 29 | const val = url.searchParams.get(key); 30 | if (val) { 31 | const parsedVal = parseInt(val); 32 | if (!isNaN(parsedVal)) { 33 | return parsedVal; 34 | } 35 | } 36 | return defaultValue; 37 | } 38 | 39 | export function getStringFromURL(url: URL, key: string, defaultValue: string): string { 40 | const val = url.searchParams.get(key); 41 | if (val) { 42 | return val; 43 | } 44 | return defaultValue; 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/components/SvgGrid.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | {#each { length: gridWidth } as _, x} 17 | {#each { length: gridHeight } as _, y} 18 | 23 | {/each} 24 | {/each} 25 | {#if showLabels} 26 | {#each { length: gridHeight } as _, x} 27 | 33 | {x} 34 | 35 | {/each} 36 | {#each { length: gridHeight } as _, y} 37 | 43 | {y} 44 | 45 | {/each} 46 | {/if} 47 | 48 | -------------------------------------------------------------------------------- /src/lib/components/Scrubber.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 | {#if $playbackState} 42 | 53 | {/if} 54 | -------------------------------------------------------------------------------- /src/lib/components/TooltipTemplateHotkeys.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 48 | 49 | 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "board", 3 | "author": "BattlesnakeOfficial (https://play.battlesnake.com)", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "vite dev --host 0.0.0.0", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "test": "npm run test:unit && npm run test:integration", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 13 | "format": "prettier --plugin-search-dir . --write .", 14 | "test:integration": "playwright test", 15 | "test:unit": "vitest run" 16 | }, 17 | "devDependencies": { 18 | "@iconify-json/heroicons": "^1.1.11", 19 | "@iconify-json/heroicons-solid": "^1.1.7", 20 | "@neoconfetti/svelte": "^1.0.0", 21 | "@playwright/test": "^1.28.1", 22 | "@sveltejs/adapter-auto": "^2.0.0", 23 | "@sveltejs/adapter-static": "^2.0.3", 24 | "@sveltejs/kit": "^1.30.3", 25 | "@tailwindcss/forms": "^0.5.4", 26 | "@types/cookie": "^0.5.1", 27 | "@types/mousetrap": "^1.6.11", 28 | "@typescript-eslint/eslint-plugin": "^5.45.0", 29 | "@typescript-eslint/parser": "^5.45.0", 30 | "autoprefixer": "^10.4.14", 31 | "eslint": "^8.28.0", 32 | "eslint-config-prettier": "^8.5.0", 33 | "eslint-plugin-svelte": "^2.30.0", 34 | "mousetrap": "^1.6.5", 35 | "postcss": "^8.4.31", 36 | "prettier": "^2.8.0", 37 | "prettier-plugin-svelte": "^2.10.1", 38 | "reconnecting-websocket": "^4.4.0", 39 | "svelte": "^4.0.0", 40 | "svelte-check": "^3.4.3", 41 | "tailwindcss": "^3.3.2", 42 | "tippy.js": "^6.3.7", 43 | "tslib": "^2.4.1", 44 | "typescript": "^5.0.0", 45 | "unplugin-icons": "^0.16.5", 46 | "vite": "^4.5.3", 47 | "vitest": "^0.32.2" 48 | }, 49 | "type": "module" 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/components/PlaybackControls.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | {#if $playbackState} 16 |
17 | 24 | 31 | {#if $playbackState.mode == PlaybackMode.PLAYING} 32 | 35 | {:else if $playbackState.mode == PlaybackMode.PAUSED} 36 | 39 | {/if} 40 | 47 | 54 |
55 | {/if} 56 | -------------------------------------------------------------------------------- /src/lib/svg.ts: -------------------------------------------------------------------------------- 1 | import type { Point } from "$lib/playback/types"; 2 | 3 | // Parameters used when drawing the gameboard svg 4 | export type SvgCalcParams = { 5 | cellSize: number; 6 | cellSizeHalf: number; 7 | cellSpacing: number; 8 | gridBorder: number; 9 | height: number; 10 | width: number; 11 | }; 12 | 13 | // Declare a new type to make it more obvious when translating board space to svg space 14 | export type SvgPoint = { 15 | x: number; 16 | y: number; 17 | }; 18 | 19 | export type SvgCircleProps = { 20 | cx: number; 21 | cy: number; 22 | }; 23 | 24 | export type SvgRectProps = { 25 | x: number; 26 | y: number; 27 | width: number; 28 | height: number; 29 | }; 30 | 31 | export function svgCalcCellCenter(params: SvgCalcParams, p: Point): SvgPoint { 32 | const topLeft = svgCalcCellTopLeft(params, p); 33 | return { 34 | x: topLeft.x + params.cellSizeHalf, 35 | y: topLeft.y + params.cellSizeHalf 36 | }; 37 | } 38 | 39 | export function svgCalcCellTopLeft(params: SvgCalcParams, p: Point): SvgPoint { 40 | return { 41 | x: params.gridBorder + p.x * (params.cellSize + params.cellSpacing), 42 | y: 43 | params.height - 44 | (params.gridBorder + p.y * (params.cellSize + params.cellSpacing) + params.cellSize) 45 | }; 46 | } 47 | 48 | export function svgCalcCellCircle(params: SvgCalcParams, p: Point): SvgCircleProps { 49 | const center = svgCalcCellCenter(params, p); 50 | return { cx: center.x, cy: center.y }; 51 | } 52 | 53 | export function svgCalcCellRect(params: SvgCalcParams, p: Point): SvgRectProps { 54 | const topLeft = svgCalcCellTopLeft(params, p); 55 | return { x: topLeft.x, y: topLeft.y, width: params.cellSize, height: params.cellSize }; 56 | } 57 | 58 | export function svgCalcCellLabelBottom(params: SvgCalcParams, p: Point): SvgPoint { 59 | const center = svgCalcCellCenter(params, p); 60 | return { 61 | x: center.x, 62 | y: center.y + params.cellSizeHalf + params.gridBorder / 2 63 | }; 64 | } 65 | 66 | export function svgCalcCellLabelLeft(params: SvgCalcParams, p: Point): SvgPoint { 67 | const center = svgCalcCellCenter(params, p); 68 | return { 69 | x: center.x - params.cellSizeHalf - params.gridBorder / 2, 70 | y: center.y 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /src/routes/(iframed)/settings/+page.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 |
18 |

Settings

19 |
20 | 21 |
22 | 30 |
31 | 32 |
33 | 41 |
42 | 43 |
44 | 48 |

Start playback as soon as the first turn is ready.

49 |
50 | 51 |
52 | 56 |

Show coordinate labels on the game board.

57 |
58 | 59 |
60 | 64 |

Show a scrubbable progress bar under the game board.

65 |
66 | 67 |

Back

68 |
69 | -------------------------------------------------------------------------------- /src/lib/components/SvgSnakeTail.svelte: -------------------------------------------------------------------------------- 1 | 59 | 60 | {#await fetchCustomizationSvgDef("tail", snake.tail) then tailSvgDef} 61 | {#if drawTail} 62 | 63 | 64 | 65 | {@html tailSvgDef} 66 | 67 | 68 | {/if} 69 | {/await} 70 | -------------------------------------------------------------------------------- /src/lib/playback/types.ts: -------------------------------------------------------------------------------- 1 | export type Point = { 2 | x: number; 3 | y: number; 4 | }; 5 | 6 | export type Elimination = { 7 | turn: number; 8 | cause: string; 9 | by: string; 10 | }; 11 | 12 | export type Snake = { 13 | id: string; 14 | name: string; 15 | author: string; 16 | color: string; 17 | head: string; 18 | tail: string; 19 | health: number; 20 | latency: number; 21 | body: Point[]; 22 | length: number; 23 | elimination: Elimination | null; 24 | // Helpers 25 | isEliminated: boolean; 26 | }; 27 | 28 | export type Frame = { 29 | turn: number; 30 | width: number; 31 | height: number; 32 | snakes: Snake[]; 33 | food: Point[]; 34 | hazards: Point[]; 35 | isFinalFrame: boolean; 36 | }; 37 | 38 | export enum PlaybackMode { 39 | PAUSED, 40 | PLAYING 41 | } 42 | 43 | export type PlaybackHandler = () => void; 44 | 45 | export type PlaybackState = { 46 | frame: Frame; 47 | mode: PlaybackMode; 48 | finalFrame: null | Frame; 49 | }; 50 | 51 | // We're lenient with typing data that's received from the game engine 52 | /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ 53 | type EngineObject = any; 54 | 55 | export function engineEventToFrame( 56 | engineGameInfo: EngineObject, 57 | engineGameEvent: EngineObject 58 | ): Frame { 59 | const engineCoordsToPoint = function (engineCoords: EngineObject): Point { 60 | return { x: engineCoords.X, y: engineCoords.Y }; 61 | }; 62 | 63 | const engineSnakeToSnake = function (engineSnake: EngineObject): Snake { 64 | return { 65 | // Fixed properties 66 | id: engineSnake.ID, 67 | name: engineSnake.Name, 68 | author: engineSnake.Author, 69 | color: engineSnake.Color, 70 | head: engineSnake.HeadType, 71 | tail: engineSnake.TailType, 72 | // Frame specific 73 | health: engineSnake.Health, 74 | latency: engineSnake.Latency, 75 | body: engineSnake.Body.map(engineCoordsToPoint), 76 | length: engineSnake.Body.length, 77 | elimination: engineSnake.Death 78 | ? { 79 | turn: engineSnake.Death.Turn, 80 | cause: engineSnake.Death.Cause, 81 | by: engineSnake.Death.EliminatedBy 82 | } 83 | : null, 84 | // Helpers 85 | isEliminated: engineSnake.Death != null 86 | }; 87 | }; 88 | 89 | return { 90 | turn: engineGameEvent.Turn, 91 | width: engineGameInfo.Game.Width, 92 | height: engineGameInfo.Game.Height, 93 | snakes: engineGameEvent.Snakes.map(engineSnakeToSnake), 94 | food: engineGameEvent.Food.map(engineCoordsToPoint), 95 | hazards: engineGameEvent.Hazards.map(engineCoordsToPoint), 96 | isFinalFrame: false 97 | }; 98 | } 99 | -------------------------------------------------------------------------------- /src/lib/playback/engine.ts: -------------------------------------------------------------------------------- 1 | import ReconnectingWebSocket from "reconnecting-websocket"; 2 | 3 | import { engineEventToFrame, type Frame } from "./types"; 4 | 5 | type FrameCallback = (frame: Frame) => void; 6 | 7 | // Engine data 8 | let ws: WebSocket; 9 | let loadedFrames = new Set(); 10 | 11 | // Converts http://foo to ws://foo or https://foo to wss://foo 12 | export function httpToWsProtocol(url: string) { 13 | return url 14 | .replace(/^https:\/\//i, "wss://") // https:// --> wss:// 15 | .replace(/^http:\/\//i, "ws://"); // http:// --> ws:// 16 | } 17 | 18 | export function fetchGame( 19 | fetchFunc: typeof fetch, 20 | gameID: string, 21 | engineURL: string, 22 | frames: Frame[], 23 | onFrameLoad: FrameCallback, 24 | onFinalFrame: FrameCallback, 25 | onError: (message: string) => void 26 | ) { 27 | console.debug(`[playback] loading game ${gameID}`); 28 | 29 | // Reset 30 | if (ws) ws.close(); 31 | loadedFrames = new Set(); 32 | 33 | const gameInfoUrl = `${engineURL}/games/${gameID}`; 34 | const gameEventsUrl = `${httpToWsProtocol(engineURL)}/games/${gameID}/events`; 35 | 36 | fetchFunc(gameInfoUrl) 37 | .then(async (response) => { 38 | if (response.status == 404) { 39 | throw new Error("Game not found"); 40 | } else if (!response.ok) { 41 | throw new Error("Error loading game"); 42 | } 43 | 44 | const gameInfo = await response.json(); 45 | const ws = new ReconnectingWebSocket(gameEventsUrl); 46 | 47 | ws.onopen = () => { 48 | console.debug("[playback] opening engine websocket"); 49 | }; 50 | 51 | ws.onmessage = (message) => { 52 | const engineEvent = JSON.parse(message.data); 53 | 54 | if (engineEvent.Type == "frame" && !loadedFrames.has(engineEvent.Data.Turn)) { 55 | loadedFrames.add(engineEvent.Data.Turn); 56 | 57 | const frame = engineEventToFrame(gameInfo, engineEvent.Data); 58 | frames.push(frame); 59 | frames.sort((a: Frame, b: Frame) => a.turn - b.turn); 60 | 61 | // Fire frame callback 62 | if (engineEvent.Data.Turn == 0) { 63 | console.debug("[playback] received first frame"); 64 | } 65 | onFrameLoad(frame); 66 | } else if (engineEvent.Type == "game_end") { 67 | console.debug("[playback] received final frame"); 68 | if (ws) ws.close(); 69 | 70 | // Flag last frame as the last one and fire callback 71 | frames[frames.length - 1].isFinalFrame = true; 72 | onFinalFrame(frames[frames.length - 1]); 73 | } 74 | }; 75 | 76 | ws.onclose = () => { 77 | console.debug("[playback] closing engine websocket"); 78 | }; 79 | }) 80 | .catch(function (e) { 81 | console.error(e); 82 | onError(e.message); 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /src/lib/components/Gameboard.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 | {#if $playbackState} 41 | 42 | 43 | 49 | 50 | 51 | {#if $highlightedSnakeID} 52 | 53 | {#each $playbackState.frame.snakes as snake} 54 | {#if snake.id !== $highlightedSnakeID} 55 | 56 | {/if} 57 | {/each} 58 | {#each $playbackState.frame.snakes as snake} 59 | {#if snake.id === $highlightedSnakeID} 60 | 61 | {/if} 62 | {/each} 63 | {:else} 64 | 65 | {#each $playbackState.frame.snakes as snake} 66 | {#if snake.isEliminated} 67 | 68 | {/if} 69 | {/each} 70 | {#each $playbackState.frame.snakes as snake} 71 | {#if !snake.isEliminated} 72 | 73 | {/if} 74 | {/each} 75 | {/if} 76 | 77 | 78 | {#each $playbackState.frame.hazards as hazard, i} 79 | 80 | {/each} 81 | 82 | 83 | {#each $playbackState.frame.food as food, i} 84 | 85 | {/each} 86 | 87 | {/if} 88 | 89 | 95 | -------------------------------------------------------------------------------- /src/lib/components/SvgSnakeHead.svelte: -------------------------------------------------------------------------------- 1 | 60 | 61 | {#await fetchCustomizationSvgDef("head", snake.head) then headSvgDef} 62 | {#if drawHead} 63 | 70 | 71 | 72 | {@html headSvgDef} 73 | 74 | 75 | {/if} 76 | {/await} 77 | 78 | 94 | -------------------------------------------------------------------------------- /src/routes/examples/iframes/+page.svelte: -------------------------------------------------------------------------------- 1 | 74 | 75 |
76 | {#each configs as config} 77 |
78 |

{config.title}

79 |
83 | 61 | ``` 62 | 63 | If you want specific settings to be used, add their values to the src URL as additional parameters. 64 | 65 | ### Sizing 66 | 67 | It's expected that the iframe element will have a width and height of 100%, and be contained by a parent with a fixed width. Board will react to the width of the container and size accordingly, with a maximum width of 1280px and a perferred aspect ratio of 16x9 on larger screens. 68 | 69 | If you know you're viewing on a larger screen, you can safely set the aspect ratio to 16x9, or fix both dimensions. 70 | 71 | ```html 72 |
73 |