diff --git a/semag/README.md b/semag/README.md
new file mode 100644
index 00000000..3e7469a1
--- /dev/null
+++ b/semag/README.md
@@ -0,0 +1,5 @@
+# Browsercraft
+
+This is a proof of concept of Minecraft running unmodified in the browser, using [CheerpJ](https://labs.leaningtech.com/cheerpj).
+
+See [the website](https://browsercraft.cheerpj.com) for a live demo and more information.
diff --git a/semag/lwjgl-2.9.0.jar b/semag/lwjgl-2.9.0.jar
new file mode 100644
index 00000000..4cb7cda8
Binary files /dev/null and b/semag/lwjgl-2.9.0.jar differ
diff --git a/semag/mc_server.html b/semag/mc_server.html
new file mode 100644
index 00000000..0325afd8
--- /dev/null
+++ b/semag/mc_server.html
@@ -0,0 +1,31 @@
+
+
+
+
+ CheerpJ test
+
+
+
+
+
+
+
+
+
diff --git a/semag/minecraft-web.js b/semag/minecraft-web.js
new file mode 100644
index 00000000..1217d4a1
--- /dev/null
+++ b/semag/minecraft-web.js
@@ -0,0 +1,192 @@
+/**
+ * Downloads a file from a url and writes it to the CheerpJ filesystem.
+ * @param {string} url
+ * @param {string} destPath
+ * @param {(downloadedBytes: number, totalBytes: number) => void} [progressCallback]
+ * @returns {Promise}
+ */
+async function downloadFileToCheerpJ(url, destPath, progressCallback) {
+ const response = await fetch(url);
+ const reader = response.body.getReader();
+ const contentLength = +response.headers.get('Content-Length');
+
+ const bytes = new Uint8Array(contentLength);
+ progressCallback?.(0, contentLength);
+
+ let pos = 0;
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done)
+ break;
+ bytes.set(value, pos);
+ pos += value.length;
+ progressCallback?.(pos, contentLength);
+ }
+
+ // Write to CheerpJ filesystem
+ return new Promise((resolve, reject) => {
+ cheerpOSOpen(cjFDs, destPath, "w", fd => {
+ cheerpOSWrite(cjFDs, fd, bytes, 0, bytes.length, w => {
+ cheerpOSClose(cjFDs, fd);
+ resolve();
+ });
+ });
+ });
+}
+
+const template = document.createElement('template');
+template.innerHTML = `
+
+
+
+
+
+ This is a proof-of-concept demo of Minecraft 1.2.5 running unmodified in the browser.
+
+
+ Clicking the button below will download the client from mojang.com.
+ By clicking it, you agree to the Minecraft EULA .
+
+
Play!
+
+ This is not an official Minecraft product. It is not approved by or associated with Mojang or Microsoft.
+
+
+
+`;
+
+export default class MinecraftClient extends HTMLElement {
+ #canvas;
+ #progress;
+ #button;
+ #display;
+ #intro;
+ #isRunning;
+
+ constructor() {
+ super();
+
+ const shadowRoot = this.attachShadow({ mode: 'open' });
+ shadowRoot.appendChild(template.content.cloneNode(true));
+
+ this.#button = shadowRoot.querySelector('button');
+ this.#button.addEventListener('click', () => this.run());
+
+ this.#canvas = shadowRoot.querySelector('canvas');
+ this.#canvas.width = 854;
+ this.#canvas.height = 480;
+ this.#canvas.tabIndex = -1;
+ this.#canvas.style.display = 'none';
+
+ this.#progress = shadowRoot.querySelector('progress');
+ this.#progress.style.display = 'none';
+
+ this.#intro = shadowRoot.querySelector('.intro');
+
+ // CheerpJ needs an element to render to, but we are going to render to own canvas
+ this.#display = shadowRoot.querySelector('.display');
+ this.#display.setAttribute('style', 'width:100%;height:100%;position:absolute;top:0;left:0px;visibility:hidden;');
+ cheerpjCreateDisplay(-1, -1, this.#display);
+
+ this.#isRunning = false;
+ }
+
+ static register() {
+ customElements.define('minecraft-client', this);
+ }
+
+ /** @returns {Promise} Exit code */
+ async run() {
+ if (this.#isRunning) {
+ throw new Error('Already running');
+ }
+
+ this.#intro.style.display = 'none';
+
+ this.#progress.style.display = 'unset';
+ const jarPath = "/files/client_1.2.5.jar"
+ await downloadFileToCheerpJ(
+ "https://piston-data.mojang.com/v1/objects/4a2fac7504182a97dcbcd7560c6392d7c8139928/client.jar",
+ jarPath,
+ (downloadedBytes, totalBytes) => {
+ this.#progress.value = downloadedBytes;
+ this.#progress.max = totalBytes;
+ }
+ );
+ this.#progress.style.display = 'none';
+
+ this.#canvas.style.display = 'unset';
+ window.lwjglCanvasElement = this.#canvas;
+ const exitCode = await cheerpjRunMain("net.minecraft.client.Minecraft", `/app/lwjgl-2.9.0.jar:/app/lwjgl_util-2.9.0.jar:${jarPath}`)
+
+ this.#canvas.style.display = 'none';
+ this.#isRunning = false;
+
+ return exitCode;
+ }
+
+ /** @returns {boolean} */
+ get isRunning() {
+ return this.#isRunning;
+ }
+}
diff --git a/semag/style.css b/semag/style.css
new file mode 100644
index 00000000..d11f5ef7
--- /dev/null
+++ b/semag/style.css
@@ -0,0 +1,92 @@
+html, body {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+
+ font-family: system-ui, sans-serif;
+}
+
+@font-face {
+ font-family: "Minecrafter";
+ src: url("fonts/minecrafter/Minecrafter.Reg.woff2"), sans-serif;
+}
+
+header {
+ background: #171615;
+ color: white;
+ padding: 1rem;
+
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+}
+
+header h1 {
+ color: #c6b8b4;
+ font: 48px Minecrafter;
+ transform: perspective(24px) translateZ(0) rotate3d(1, 0, 0, 2deg);
+ text-shadow: 0px 5px 4px black;
+}
+
+main {
+ margin: 0 auto;
+ padding: 1rem;
+ max-width: 60ch;
+ width: 100%;
+ box-sizing: border-box;
+ line-height: 1.4;
+}
+
+main h2 {
+ font-size: 1.5rem;
+ margin: 1rem 0 0.5rem;
+}
+
+main li {
+ margin: 0.5rem 0;
+}
+
+@media (max-width: 900px) {
+ minecraft-client {
+ width: 100%;
+ }
+}
+
+.controls {
+ margin: 0.5rem 0;
+}
+
+.controls > * {
+ appearance: none;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+
+ width: 24px;
+ height: 24px;
+
+ margin-right: 0.5rem;
+}
+
+.controls svg {
+ stroke: #c6b8b4;
+}
+
+.controls > *:hover svg {
+ stroke: white;
+}
+
+.mobile-only {
+ display: none;
+}
+
+@media (max-width: 854px) {
+ .mobile-only {
+ display: block;
+ }
+
+ .desktop-only {
+ display: none;
+ }
+}