diff --git a/web/public/fullscreen-close.svg b/web/public/fullscreen-close.svg
new file mode 100644
index 00000000..63ecc7e3
--- /dev/null
+++ b/web/public/fullscreen-close.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/web/public/fullscreen-open.svg b/web/public/fullscreen-open.svg
new file mode 100644
index 00000000..9e8d031e
--- /dev/null
+++ b/web/public/fullscreen-open.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/web/public/index.html b/web/public/index.html
deleted file mode 100644
index a9da110f..00000000
--- a/web/public/index.html
+++ /dev/null
@@ -1,113 +0,0 @@
-
-
-
-
- Quadradius
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/src/app/(main)/index.module.css b/web/src/app/(main)/index.module.css
new file mode 100644
index 00000000..466bd458
--- /dev/null
+++ b/web/src/app/(main)/index.module.css
@@ -0,0 +1,28 @@
+.container {
+ width: 100%;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: stretch;
+ align-content: stretch;
+ position: relative;
+}
+
+.game {
+ flex: 1;
+}
+
+.embed {
+ width: 100%;
+ height: 100%;
+}
+
+.directions {
+ position: absolute;
+ right: 8px;
+ z-index: 1;
+ top: 8px;
+ font-size: clamp(0.8rem, 1vw, 1rem);
+}
diff --git a/web/src/app/(main)/layout.tsx b/web/src/app/(main)/layout.tsx
index 8aa91099..1fb368c0 100644
--- a/web/src/app/(main)/layout.tsx
+++ b/web/src/app/(main)/layout.tsx
@@ -1,3 +1,15 @@
+import '@/styles/globals.css'
+
+export const metadata = {
+ title: 'Quadradius',
+ description: 'Quadradius is a two-player turn-based strategy game from 2007 made in Flash. A game of skill, luck, and deception, with the gameplay of checkerboards on steroids. Originally created by Jimmi Heiserman and Brad Kayal.',
+};
+
+export const viewport = {
+ width: 'device-width',
+ initialScale: 1,
+};
+
export default function MainLayout({
children,
}: {
diff --git a/web/src/app/(main)/page.tsx b/web/src/app/(main)/page.tsx
index d09daed8..20358d04 100644
--- a/web/src/app/(main)/page.tsx
+++ b/web/src/app/(main)/page.tsx
@@ -1,5 +1,30 @@
+'use client';
+
+import styles from './index.module.css';
+import Ruffle from '@/components/ruffle/ruffle';
+import FullscreenToggle from '@/components/ui/fullscreen-toggle/fullscreen-toggle';
+import Footer from "@/components/ui/footer/footer";
+import { useUnloadWarning } from '@/hooks/use-unload-warning';
+
+
export default function Home() {
+ useUnloadWarning();
+
return (
- Quadradius Web App
+ <>
+
+
+
+
+
+
+
+
+
+ >
)
}
diff --git a/web/src/components/ruffle/ruffle-config.tsx b/web/src/components/ruffle/ruffle-config.tsx
new file mode 100644
index 00000000..d37baafd
--- /dev/null
+++ b/web/src/components/ruffle/ruffle-config.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export default function RuffleConfig() {
+ useEffect(() => {
+ const host = window.location.host;
+ const protocol = window.location.protocol == 'http:' ? 'ws:' : 'wss:';
+
+ window.RufflePlayer = window.RufflePlayer || {};
+ window.RufflePlayer.config = {
+ autoplay: "on",
+ contextMenu: "off",
+ logLevel: "debug",
+ socketProxy: [
+ {
+ host: "127.0.0.1",
+ port: 3000,
+ proxyUrl: `${protocol}//${host}/websocket/lobby`,
+ },
+ {
+ host: "127.0.0.1",
+ port: 3001,
+ proxyUrl: `${protocol}//${host}/websocket/game`,
+ },
+ ],
+ };
+ }, []);
+
+ return null;
+}
diff --git a/web/src/components/ruffle/ruffle-loader.tsx b/web/src/components/ruffle/ruffle-loader.tsx
new file mode 100644
index 00000000..2083efdf
--- /dev/null
+++ b/web/src/components/ruffle/ruffle-loader.tsx
@@ -0,0 +1,27 @@
+'use client';
+
+import Script from 'next/script';
+import { useSearchParams } from 'next/navigation';
+import config from '@/configurations/config.json';
+
+export default function RuffleLoader() {
+ const searchParams = useSearchParams();
+
+ const queryVersion = searchParams.get('ruffle_version');
+
+ const version =
+ queryVersion ??
+ config.ruffleVersion ??
+ '';
+
+ const ruffleUrl = version
+ ? `https://unpkg.com/@ruffle-rs/ruffle@${version}/ruffle.js`
+ : 'https://unpkg.com/@ruffle-rs/ruffle';
+
+ return (
+
+ );
+}
diff --git a/web/src/components/ruffle/ruffle.tsx b/web/src/components/ruffle/ruffle.tsx
new file mode 100644
index 00000000..fe4afef0
--- /dev/null
+++ b/web/src/components/ruffle/ruffle.tsx
@@ -0,0 +1,14 @@
+import { Suspense } from 'react';
+import RuffleConfig from './ruffle-config';
+import RuffleLoader from './ruffle-loader';
+
+export default function Ruffle() {
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/ui/footer/footer.module.css b/web/src/components/ui/footer/footer.module.css
new file mode 100644
index 00000000..7784c6cd
--- /dev/null
+++ b/web/src/components/ui/footer/footer.module.css
@@ -0,0 +1,7 @@
+.footer {
+ padding: 0.3em;
+ color: #444444;
+ text-align: center;
+ font-size: clamp(0.8rem, 1vw, 1rem);
+}
+
diff --git a/web/src/components/ui/footer/footer.tsx b/web/src/components/ui/footer/footer.tsx
new file mode 100644
index 00000000..e7f1a73f
--- /dev/null
+++ b/web/src/components/ui/footer/footer.tsx
@@ -0,0 +1,9 @@
+import styles from './footer.module.css';
+
+export default function Footer() {
+ return (
+
+ );
+}
diff --git a/web/src/components/ui/fullscreen-toggle/fullscreen-toggle.module.css b/web/src/components/ui/fullscreen-toggle/fullscreen-toggle.module.css
new file mode 100644
index 00000000..17e954c0
--- /dev/null
+++ b/web/src/components/ui/fullscreen-toggle/fullscreen-toggle.module.css
@@ -0,0 +1,40 @@
+.fullscreen {
+ position: absolute;
+ left: 10px;
+ top: 10px;
+ z-index: 10;
+}
+
+.toggle {
+ position: relative;
+ width: 32px;
+ height: 32px;
+ background: rgba(255, 255, 255, 0.7);
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ padding: 5px;
+ cursor: pointer;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ opacity: 0.25;
+}
+
+.toggle img {
+ inset: 10px;
+}
+
+.toggle:hover {
+ background: rgba(255, 255, 255, 0.9);
+ opacity: 0.8;
+}
+
+.toggle img {
+ padding: 4px;
+}
+
+@media (hover: none) {
+ .toggle {
+ display: flex;
+ }
+}
diff --git a/web/src/components/ui/fullscreen-toggle/fullscreen-toggle.tsx b/web/src/components/ui/fullscreen-toggle/fullscreen-toggle.tsx
new file mode 100644
index 00000000..6595fa9e
--- /dev/null
+++ b/web/src/components/ui/fullscreen-toggle/fullscreen-toggle.tsx
@@ -0,0 +1,44 @@
+'use client';
+
+import Image from 'next/image';
+import { useEffect, useRef } from 'react';
+import styles from './fullscreen-toggle.module.css';
+
+export default function FullscreenToggle() {
+ const openIconRef = useRef(null);
+ const closeIconRef = useRef(null);
+
+ useEffect(() => {
+ const openIcon = openIconRef.current;
+ const closeIcon = closeIconRef.current;
+ if (!openIcon || !closeIcon) return;
+
+ closeIcon.style.display = 'none';
+
+ const updateIcons = (isFullscreen: boolean) => {
+ openIcon.style.display = isFullscreen ? 'none' : 'block';
+ closeIcon.style.display = isFullscreen ? 'block' : 'none';
+ };
+
+ const handleFullscreenChange = () => updateIcons(!!document.fullscreenElement);
+ document.addEventListener('fullscreenchange', handleFullscreenChange);
+ return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
+ }, []);
+
+ const handleClick = () => {
+ if (!document.fullscreenElement) {
+ void document.documentElement.requestFullscreen();
+ } else {
+ void document.exitFullscreen();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/web/src/configurations/config.json b/web/src/configurations/config.json
index ed17e693..b04925f9 100644
--- a/web/src/configurations/config.json
+++ b/web/src/configurations/config.json
@@ -1,4 +1,5 @@
{
+ "ruffleVersion": "0.2.0-nightly.2026.4.21",
"discordChannel": "https://discord.com/channels/1197227968112627802",
"discordRegistration": "https://discord.com/channels/1197227968112627802/1297809215066341396"
}
diff --git a/web/src/hooks/use-unload-warning.js b/web/src/hooks/use-unload-warning.js
new file mode 100644
index 00000000..f0ad8e9c
--- /dev/null
+++ b/web/src/hooks/use-unload-warning.js
@@ -0,0 +1,17 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export function useUnloadWarning(enabled = true) {
+ useEffect(() => {
+ if (!enabled) return;
+
+ const handleBeforeUnload = (e) => {
+ e.preventDefault();
+ e.returnValue = '';
+ };
+
+ window.addEventListener('beforeunload', handleBeforeUnload);
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload);
+ }, [enabled]);
+}
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index e2bca192..81ecd8dc 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -28,10 +28,26 @@
--button-text: #ffffff;
}
+html,
body {
- color: var(--foreground);
- background: var(--background);
- font-family: var(--font-geist-sans), Arial, sans-serif;
+ margin: 0;
+ padding: 0;
+ background-color: black;
+ color: white;
+ font-family: sans-serif;
+}
+
+a {
+ text-decoration: none;
+}
+
+a:link,
+a:visited {
+ color: #777777;
+}
+
+a:hover {
+ color: #aaaaaa;
}
/* Header with background image */
diff --git a/web/src/types/declarations.d.ts b/web/src/types/declarations.d.ts
new file mode 100644
index 00000000..cbe652db
--- /dev/null
+++ b/web/src/types/declarations.d.ts
@@ -0,0 +1 @@
+declare module "*.css";
diff --git a/web/src/types/ruffle.d.ts b/web/src/types/ruffle.d.ts
new file mode 100644
index 00000000..1ddbac3c
--- /dev/null
+++ b/web/src/types/ruffle.d.ts
@@ -0,0 +1,8 @@
+interface RufflePlayerConfig {
+ config?: Record;
+ [key: string]: unknown;
+}
+
+interface Window {
+ RufflePlayer?: RufflePlayerConfig;
+}