}
+```
+
+Then add `new YourExample()` to the `Examples` list in `Host.cs`.
+
+Examples that load files (`resources/...`) also need those assets in the wasm virtual filesystem.
+`Examples.csproj` bundles `../resources/` into the browser app when publishing with
+`RuntimeIdentifier=browser-wasm`.
diff --git a/Examples/Web/index.html b/Examples/Web/index.html
new file mode 100644
index 0000000..18b86fc
--- /dev/null
+++ b/Examples/Web/index.html
@@ -0,0 +1,70 @@
+
+
+
+
+
+ raylib-cs — browser examples
+
+
+
+
+
+
+ raylib-cs running in the browser (WebAssembly)
+
+
+
+
+
+
+
+
+
+ Initializing .NET + raylib…
+
+
+
+
diff --git a/Examples/Web/main.js b/Examples/Web/main.js
new file mode 100644
index 0000000..99bf3a1
--- /dev/null
+++ b/Examples/Web/main.js
@@ -0,0 +1,157 @@
+import { dotnet } from './_framework/dotnet.js'
+import {
+ CANVAS_WIDTH,
+ CANVAS_HEIGHT,
+ normalizeScaleMode,
+ computeDisplaySize
+} from './scaleUtils.js'
+
+const status = document.getElementById('status');
+const select = document.getElementById('examples');
+const canvas = document.getElementById('canvas');
+const viewport = document.getElementById('viewport');
+const scaleModeSelect = document.getElementById('scaleMode');
+
+function readScaleModeFromQuery() {
+ const params = new URLSearchParams(window.location.search);
+ return normalizeScaleMode(params.get('scale'));
+}
+
+function writeScaleModeToQuery(mode) {
+ const params = new URLSearchParams(window.location.search);
+ params.set('scale', mode);
+ const query = params.toString();
+ const nextUrl = query.length > 0 ? `${window.location.pathname}?${query}` : window.location.pathname;
+ window.history.replaceState(null, '', nextUrl);
+}
+
+let scaleMode = readScaleModeFromQuery();
+let Host = null;
+
+function ensureBackingBufferSize() {
+ if (canvas.width !== CANVAS_WIDTH) {
+ canvas.width = CANVAS_WIDTH;
+ }
+
+ if (canvas.height !== CANVAS_HEIGHT) {
+ canvas.height = CANVAS_HEIGHT;
+ }
+}
+
+function updateScaleDiagnostics(displayScale, cssWidth, cssHeight, deviceWidth, deviceHeight, dpr) {
+ status.textContent =
+ `Running. Example: ${scaleMode} scaling | DPR ${dpr.toFixed(2)} | ` +
+ `CSS ${cssWidth.toFixed(2)}x${cssHeight.toFixed(2)} | ` +
+ `Device ${deviceWidth}x${deviceHeight} | Backing ${canvas.width}x${canvas.height} | ` +
+ `Scale ${displayScale.toFixed(2)}`;
+}
+
+function syncMouseScale() {
+ if (!Host) {
+ return;
+ }
+
+ const cssWidth = canvas.clientWidth;
+ const cssHeight = canvas.clientHeight;
+ if (cssWidth <= 0 || cssHeight <= 0) {
+ return;
+ }
+
+ // Map CSS/display mouse coords to the fixed backing buffer (canvas.width/height).
+ const scaleX = canvas.width / cssWidth;
+ const scaleY = canvas.height / cssHeight;
+ Host.SetMouseScaleFromDisplay(scaleX, scaleY);
+}
+
+function applyDisplayScale() {
+ ensureBackingBufferSize();
+
+ const viewportWidth = viewport.clientWidth || CANVAS_WIDTH;
+ const viewportHeight = viewport.clientHeight || CANVAS_HEIGHT;
+ const dprRaw = window.devicePixelRatio ?? 1;
+ const display = computeDisplaySize(scaleMode, viewportWidth, viewportHeight, dprRaw);
+
+ canvas.style.width = `${display.cssWidth}px`;
+ canvas.style.height = `${display.cssHeight}px`;
+
+ // Center canvas inside viewport, leaving letterboxing around it.
+ viewport.style.justifyContent = 'center';
+ viewport.style.alignItems = 'center';
+
+ syncMouseScale();
+
+ updateScaleDiagnostics(
+ display.scale,
+ display.cssWidth,
+ display.cssHeight,
+ display.deviceWidth,
+ display.deviceHeight,
+ display.dpr
+ );
+}
+
+function setScaleMode(mode, updateQuery) {
+ const nextMode = normalizeScaleMode(mode);
+ if (nextMode === scaleMode && !updateQuery) {
+ return;
+ }
+
+ scaleMode = nextMode;
+ if (scaleModeSelect) {
+ scaleModeSelect.value = scaleMode;
+ }
+
+ if (updateQuery) {
+ writeScaleModeToQuery(scaleMode);
+ }
+
+ applyDisplayScale();
+}
+
+if (scaleModeSelect) {
+ scaleModeSelect.value = scaleMode;
+ scaleModeSelect.addEventListener('change', () => {
+ setScaleMode(scaleModeSelect.value, true);
+ });
+}
+
+window.addEventListener('resize', applyDisplayScale);
+if (window.visualViewport) {
+ window.visualViewport.addEventListener('resize', applyDisplayScale);
+}
+
+applyDisplayScale();
+
+const { getAssemblyExports, getConfig, runMain } = await dotnet
+ .withDiagnosticTracing(false)
+ .create();
+
+const config = getConfig();
+const exports = await getAssemblyExports(config.mainAssemblyName);
+Host = exports.Examples.Web.Host;
+
+// raylib's GLFW/emscripten backend renders into this canvas.
+dotnet.instance.Module['canvas'] = canvas;
+
+// Runs Host.Main() -> InitWindow + default example Init(). Must come after the canvas is bound.
+await runMain();
+applyDisplayScale();
+
+// Populate the navigation dropdown from the registered examples.
+select.innerHTML = '';
+for (const name of Host.GetExampleNames().split('\n').filter(n => n.length > 0)) {
+ const opt = document.createElement('option');
+ opt.value = name;
+ opt.textContent = name;
+ select.appendChild(opt);
+}
+select.addEventListener('change', () => Host.SetExample(select.value));
+
+setScaleMode(scaleMode, false);
+
+// Drive raylib one frame per animation tick (never block the browser).
+function mainLoop() {
+ Host.UpdateFrame();
+ requestAnimationFrame(mainLoop);
+}
+requestAnimationFrame(mainLoop);
diff --git a/Examples/Web/scaleUtils.js b/Examples/Web/scaleUtils.js
new file mode 100644
index 0000000..e1e4b4f
--- /dev/null
+++ b/Examples/Web/scaleUtils.js
@@ -0,0 +1,67 @@
+export const CANVAS_WIDTH = 800;
+export const CANVAS_HEIGHT = 450;
+export const SCALE_MODES = new Set(['integer', 'native', 'fit']);
+export const DEFAULT_SCALE_MODE = 'native';
+
+export function normalizeScaleMode(mode) {
+ if (!mode) {
+ return DEFAULT_SCALE_MODE;
+ }
+
+ const normalized = String(mode).toLowerCase();
+ return SCALE_MODES.has(normalized) ? normalized : DEFAULT_SCALE_MODE;
+}
+
+export function computeDisplaySize(mode, viewportWidth, viewportHeight, dpr = 1) {
+ const safeDpr = Number.isFinite(dpr) && dpr > 0 ? dpr : 1;
+
+ if (viewportWidth <= 0 || viewportHeight <= 0) {
+ return {
+ cssWidth: CANVAS_WIDTH / safeDpr,
+ cssHeight: CANVAS_HEIGHT / safeDpr,
+ deviceWidth: CANVAS_WIDTH,
+ deviceHeight: CANVAS_HEIGHT,
+ scale: 1,
+ dpr: safeDpr
+ };
+ }
+
+ // Convert viewport from CSS pixels to device pixels first so scaling is DPI/zoom aware.
+ const viewportDeviceWidth = Math.max(1, Math.floor(viewportWidth * safeDpr));
+ const viewportDeviceHeight = Math.max(1, Math.floor(viewportHeight * safeDpr));
+
+ let deviceWidth;
+ let deviceHeight;
+ let scale;
+
+ if (mode === 'native') {
+ // 1 source pixel -> 1 physical device pixel.
+ scale = 1;
+ deviceWidth = CANVAS_WIDTH;
+ deviceHeight = CANVAS_HEIGHT;
+ } else if (mode === 'fit') {
+ const fitRatio = Math.min(viewportDeviceWidth / CANVAS_WIDTH, viewportDeviceHeight / CANVAS_HEIGHT);
+ const safeRatio = Number.isFinite(fitRatio) && fitRatio > 0 ? fitRatio : 1;
+ scale = safeRatio;
+ deviceWidth = Math.max(1, Math.floor(CANVAS_WIDTH * safeRatio));
+ deviceHeight = Math.max(1, Math.floor(CANVAS_HEIGHT * safeRatio));
+ } else {
+ // integer mode
+ const integerScale = Math.max(1, Math.floor(Math.min(
+ viewportDeviceWidth / CANVAS_WIDTH,
+ viewportDeviceHeight / CANVAS_HEIGHT
+ )));
+ scale = integerScale;
+ deviceWidth = CANVAS_WIDTH * integerScale;
+ deviceHeight = CANVAS_HEIGHT * integerScale;
+ }
+
+ return {
+ cssWidth: deviceWidth / safeDpr,
+ cssHeight: deviceHeight / safeDpr,
+ deviceWidth,
+ deviceHeight,
+ scale,
+ dpr: safeDpr
+ };
+}
diff --git a/Examples/Web/scaleUtils.test.js b/Examples/Web/scaleUtils.test.js
new file mode 100644
index 0000000..cecb25c
--- /dev/null
+++ b/Examples/Web/scaleUtils.test.js
@@ -0,0 +1,92 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ CANVAS_WIDTH,
+ CANVAS_HEIGHT,
+ normalizeScaleMode,
+ computeDisplaySize
+} from './scaleUtils.js';
+
+describe('normalizeScaleMode', () => {
+ it('returns native for null, undefined, and empty string', () => {
+ assert.equal(normalizeScaleMode(null), 'native');
+ assert.equal(normalizeScaleMode(undefined), 'native');
+ assert.equal(normalizeScaleMode(''), 'native');
+ });
+
+ it('normalizes allowlisted values case-insensitively', () => {
+ assert.equal(normalizeScaleMode('INTEGER'), 'integer');
+ assert.equal(normalizeScaleMode('Fit'), 'fit');
+ assert.equal(normalizeScaleMode('native'), 'native');
+ });
+
+ it('returns native for unknown values', () => {
+ assert.equal(normalizeScaleMode('stretch'), 'native');
+ assert.equal(normalizeScaleMode('bogus'), 'native');
+ });
+});
+
+describe('computeDisplaySize', () => {
+ it('native mode uses 1:1 device pixels at scale 1', () => {
+ const result = computeDisplaySize('native', 1200, 800, 1);
+
+ assert.equal(result.scale, 1);
+ assert.equal(result.deviceWidth, CANVAS_WIDTH);
+ assert.equal(result.deviceHeight, CANVAS_HEIGHT);
+ assert.equal(result.cssWidth, CANVAS_WIDTH);
+ assert.equal(result.cssHeight, CANVAS_HEIGHT);
+ assert.equal(result.dpr, 1);
+ });
+
+ it('integer mode scales by floored ratio with minimum scale 1', () => {
+ const doubled = computeDisplaySize('integer', 1600, 900, 1);
+
+ assert.equal(doubled.scale, 2);
+ assert.equal(doubled.deviceWidth, CANVAS_WIDTH * 2);
+ assert.equal(doubled.deviceHeight, CANVAS_HEIGHT * 2);
+
+ const single = computeDisplaySize('integer', 900, 500, 1);
+
+ assert.equal(single.scale, 1);
+ assert.equal(single.deviceWidth, CANVAS_WIDTH);
+ assert.equal(single.deviceHeight, CANVAS_HEIGHT);
+ });
+
+ it('fit mode scales proportionally and keeps device dimensions at least 1', () => {
+ const result = computeDisplaySize('fit', 400, 225, 1);
+
+ assert.equal(result.scale, 0.5);
+ assert.equal(result.deviceWidth, 400);
+ assert.equal(result.deviceHeight, 225);
+ });
+
+ it('fit mode falls back to scale 1 for zero or negative viewport', () => {
+ const zero = computeDisplaySize('fit', 0, 0, 1);
+
+ assert.equal(zero.scale, 1);
+ assert.equal(zero.deviceWidth, CANVAS_WIDTH);
+ assert.equal(zero.deviceHeight, CANVAS_HEIGHT);
+
+ const negative = computeDisplaySize('fit', -100, -50, 1);
+
+ assert.equal(negative.scale, 1);
+ assert.equal(negative.deviceWidth, CANVAS_WIDTH);
+ assert.equal(negative.deviceHeight, CANVAS_HEIGHT);
+ });
+
+ it('applies dpr to viewport before scaling and converts css size back', () => {
+ const result = computeDisplaySize('native', 400, 225, 2);
+
+ assert.equal(result.dpr, 2);
+ assert.equal(result.deviceWidth, CANVAS_WIDTH);
+ assert.equal(result.deviceHeight, CANVAS_HEIGHT);
+ assert.equal(result.cssWidth, CANVAS_WIDTH / 2);
+ assert.equal(result.cssHeight, CANVAS_HEIGHT / 2);
+ });
+
+ it('treats invalid dpr as 1', () => {
+ assert.equal(computeDisplaySize('native', 800, 450, 0).dpr, 1);
+ assert.equal(computeDisplaySize('native', 800, 450, NaN).dpr, 1);
+ assert.equal(computeDisplaySize('native', 800, 450, Infinity).dpr, 1);
+ });
+});
diff --git a/README.md b/README.md
index 406665f..18a8839 100644
--- a/README.md
+++ b/README.md
@@ -65,25 +65,6 @@ otherwise the command won't work.
5. Start coding!
-## Building from source
-
-The `Examples` and `Raylib-cs.Tests` projects consume the binding as a NuGet package, and the in-repo
-version may not be published on nuget.org yet. Pack it once into the local feed before the first build:
-
-```
-dotnet pack Raylib-cs -c Release -o nuget
-dotnet build
-```
-
-`NuGet.config` points restore at the local `./nuget` feed, so both projects pick up the freshly packed
-package. If you skip the pack step on a fresh clone, restore fails with NU1301 (the `./nuget` source
-doesn't exist) or NU1102 (Raylib-cs not found) — running the pack command fixes both.
-
-When iterating on the binding itself, note that NuGet caches the extracted package by version and
-ignores repacks of the same version. After repacking, delete the old package from `./nuget` and clear
-the cached copy (`dotnet nuget locals global-packages --clear`, or delete
-`~/.nuget/packages/raylib-cs/`) before restoring again.
-
## Hello, World!
```csharp