Wasmbot is a Gradle plugin and source-bundled runtime that compiles ordinary FRC robot programs to
WebAssembly with GraalVM Web Image. Robot code written against WPILib
Commands V2, Commands V3, or AdvantageKit is built unmodified: the same Robot class, the same
constructors, and the same robotPeriodic() lifecycle run on hardware, on a desktop simulation, and
in the browser. There is no separate browser-only simulation lifecycle to maintain.
The published plugin identifier is org.teamvan.wasmbot.
- Requirements
- Installation
- Configuration
- Robot entry point
- Building and running locally
- Publishing the simulator with GitHub Pages
- Architecture
- Supported surface and limitations
- Examples
- Licensing
| Prerequisite | Version | Needed for |
|---|---|---|
| GraalVM JDK with Web Image | 25 | Compiling and linking the WebAssembly image |
| Gradle | 9.4.1 | Building the robot project |
| Binaryen | version_133 |
wasm-opt and wasm-as post-processing |
| WPILib | 2027.0.0-alpha-7 |
Robot code, with Commands V2 or Commands V3 |
| JSPI-capable browser | Current release | The browser simulator and telemetry viewer |
| Node.js and Emscripten | 22 and 4.0.12 | Only when the bundled viewer is rebuilt from source |
The Web Image tool must be present as native-image --tool:svm-wasm, and the
org.graalvm.webimage.api module must be available. An ordinary Java 25 JDK cannot compile or link
the browser target. The browser must provide WebAssembly.Suspending and
WebAssembly.promising.
Binaryen is pinned because wasm-opt output and option handling vary between releases; the
repository's continuous integration installs the published version_133 archive rather than a
distribution package, which may lag behind it. Commands V2 and Commands V3 cannot be installed in
the same project.
Install a GraalVM 25 JDK, Binaryen, and your platform's native compiler toolchain. Point
JAVA_HOME and GRAALVM_HOME at the GraalVM installation, then add its bin directory to the
executable search path so java, javac, and native-image resolve to that installation.
The exact commands differ per platform; the following outline is sufficient in each case.
Windows
- Install the GraalVM JDK and Binaryen, and install the Visual Studio C++ build tools including the x64 workload.
- Build from a developer shell provided by those build tools, so that the compiler environment is already initialized.
- Set
JAVA_HOMEandGRAALVM_HOMEto the GraalVM installation directory, prepend itsbindirectory toPATH, and confirm that both the host and target architecture reported by the environment arex64. - Use
.\gradlew.batwherever the commands below show./gradlew.
macOS
- Install the GraalVM JDK (for example through a version manager or an archive), Xcode command line tools, and Binaryen.
- Export
JAVA_HOMEandGRAALVM_HOME, and prepend the GraalVMbindirectory toPATH. - If macOS quarantines a downloaded toolchain, clear the quarantine attribute before use.
Linux
- Install the GraalVM JDK archive, a C/C++ toolchain (
gccandg++orclang),zlib, and Binaryen through your distribution's package manager. - Export
JAVA_HOMEandGRAALVM_HOME, and prepend the GraalVMbindirectory toPATH.
If a platform package manager offers an older Binaryen than the pinned release, install the release
archive instead and add its bin directory to the search path.
Verify the toolchain before installing the plugin:
java -version
native-image --version
native-image --tool:svm-wasm --version
wasm-opt --versionThe first two commands must report GraalVM 25, and the third must not fail with an unknown-tool error. A JDK that reports Java 25 but lacks Web Image cannot build the browser target.
Published artifact (recommended). WPILib's generated pluginManagement block already declares
the Gradle Plugin Portal, so no additional repository is required. Add the plugin to the
plugins block of the robot project:
plugins {
id 'org.teamvan.wasmbot' version '0.1.0'
}The implementation artifact is published as org.teamvan.wasmbot:wasmbot:0.1.0 to the Gradle Plugin
Portal and to Maven Central.
Included source build. Place this repository inside the robot project under a relative path and
add it to the existing pluginManagement block in settings.gradle:
includeBuild('wasmbot')Then declare the unversioned identifier in the robot project's plugins block:
plugins {
id 'org.teamvan.wasmbot'
}An included build tracks source and resource changes automatically and is convenient while developing the runtime itself.
For local verification of a release candidate, publish the plugin to the Maven local repository
(./gradlew check assemble publishToMavenLocal from this repository) and add mavenLocal() before
the plugin portal in the consumer's pluginManagement.repositories.
Do not add the runtime as a desktop implementation dependency. The plugin attaches it only to the
WebAssembly source sets; desktop and hardware builds keep the ordinary WPILib implementation.
Configure the robot's real entry point and the native artifact name through the wasmbot
extension:
wasmbot {
mainClass.set('example.Main')
imageName.set('robot')
}| Property | Default | Meaning |
|---|---|---|
mainClass |
Required | Ordinary entry point calling RobotBase.startRobot(...). |
imageName |
wasm |
Base name of the generated native artifact, without extensions. |
The plugin applies the GraalVM native build plugin itself. Remove legacy local adapters, copied runtime sources, custom native-image definitions, and hand-written loader patches when migrating; those responsibilities now belong to the plugin.
Use the same entry point as any WPILib project:
package example;
import org.wpilib.framework.RobotBase;
public final class Main {
private Main() {}
public static void main(String... args) {
RobotBase.startRobot(Robot::new);
}
}The robot's constructor performs initialization. Wasmbot constructs the real Robot once, invokes
simulationInit() once, and then drives the ordinary iterative lifecycle. robotPeriodic() runs in
both enabled and disabled cycles, and the application remains responsible for its own scheduler
call, input sampling, and output publication, in the order appropriate for the project:
@Override
public void robotPeriodic() {
Scheduler.getDefault().run();
Telemetry.log("Example/Counter", counter.ticks());
}Shared robot code can detect the browser target without importing any Wasmbot class:
boolean runningUnderWasm = Boolean.getBoolean("wasmbot");The wasmbot system property is set before the constructor runs and is absent on desktop and
hardware launches.
./gradlew nativeCompile
./gradlew wasmSite advantagescope| Task | Result |
|---|---|
nativeCompile |
Produces the WebAssembly image and its generated JavaScript loader. |
wasmSite |
Stages the browser simulator and the native image as a static site. |
advantagescope |
Stages the bundled AdvantageScope Lite telemetry viewer alongside that site. |
Serve the staged site over HTTP rather than opening it from the filesystem; module loading and
WebAssembly instantiation require an origin. Any static file server works. Run it from the staged
site directory, for example:
python3 -m http.server 8099Then open http://localhost:8099/ in a JSPI-capable browser.
The simulator panel starts disabled and in the unknown mode. Select Teleop, Auto, or Utility, choose a published opmode, and then enable the robot. Losing focus or hiding the page disables the robot and releases held inputs without changing the selected mode. Keyboard bindings, controller port assignments, deadzone, and axis inversion are configurable from the panel:
| Keyboard | Gamepad input |
|---|---|
| W/S, A/D | Left Y, left X |
| Q/E, R/F | Right X, right Y |
| Z/C | Left and right triggers |
| J/K/U/I | Face down/right/left/up |
| 1/3 | Left and right bumpers |
| B/Enter, Home | Back/start, guide |
| V/N, arrows | Stick clicks, D-pad |
| Shift | Red alliance while held |
Opmodes are registered in the robot constructor and published once; the panel lists them for the current mode and falls back to the framework lifecycle when none is selected. Standard browser gamepads are mapped automatically, and non-standard mappings are reported as unsupported rather than guessed.
The staged site is a plain static directory, so it can be deployed directly to GitHub Pages. Enable
Pages for the repository, choose GitHub Actions as the source, and commit the following workflow
as .github/workflows/pages.yaml. It provisions the three build prerequisites — Binaryen, a
GraalVM JDK, and Gradle — with pinned actions, then builds and uploads the staged site:
name: pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Check out the repository
uses: actions/checkout@v7
- name: Set up Binaryen
shell: bash
run: |
set -euo pipefail
version="version_133"
archive="binaryen-${version}-x86_64-linux.tar.gz"
url="https://github.com/WebAssembly/binaryen/releases/download/${version}/${archive}"
curl --fail --location --retry 3 --output "${RUNNER_TEMP}/${archive}" "${url}"
mkdir -p "${RUNNER_TEMP}/binaryen"
tar --extract --gzip --file "${RUNNER_TEMP}/${archive}" --directory "${RUNNER_TEMP}/binaryen" --strip-components=1
echo "${RUNNER_TEMP}/binaryen/bin" >> "${GITHUB_PATH}"
- name: Set up JDK 25
uses: actions/setup-java@v6
with:
distribution: graalvm
java-version: "25"
- name: Set up Gradle 9.4.1
uses: gradle/actions/setup-gradle@v6
with:
gradle-version: "9.4.1"
- name: Build the simulator
run: gradle --no-daemon wasmSite advantagescope
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: build/wasm/site
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4Notes:
actions/setup-javasupplies the GraalVM distribution, andgradle/actions/setup-gradlesupplies the pinned Gradle version, so the runner needs no preinstalled toolchain. Confirm that the selected GraalVM release includes Web Image by runningnative-image --tool:svm-wasm --versiononce locally; without it, compilation fails as a missingorg.graalvm.webimage.apitype.- Binaryen is installed from its pinned release archive rather than from the distribution package,
so
wasm-optmatches the version the image pipeline was validated against. wasmSitebuilds the native image before staging, so a separatenativeCompilestep is unnecessary. The workflow uploads the staged site directory produced bywasmSiteandadvantagescope; adjust only that upload path if your project stages its site elsewhere.- A project that consumes Wasmbot through an included source build also compiles the bundled viewer and therefore additionally needs Node.js 22 and Emscripten 4.0.12, as this repository's own workflow does. A project that consumes the published plugin does not.
- Pin the plugin version in
build.gradle, so every deployment builds against the same runtime. The workflow invokes the Gradle version installed by the setup step; a project that requires its own wrapper can substitute./gradlewand dropgradle-versionfrom that step.
Wasmbot is a source-bundled runtime rather than a fork of WPILib. The plugin packages a small JavaScript host layer, a set of Java runtime replacements, and the native build configuration. Each consumer project compiles those runtime sources against its own WPILib and vendor dependencies, so the runtime always matches the library versions the robot was written for.
The desktop classpath is untouched. A separate WebAssembly compilation isolates the runtime
replacements and keeps them out of the robot JAR; shared robot code observes only the wasmbot
system property. When a project supplies its own replacement for a package, that replacement takes
precedence over the bundled one.
In the browser, a thin host layer supplies the services a robot normally obtains from the driver station and the native HAL: a monotonic clock, driver-station state, controller data, and an in-process NetworkTables transport. The host advances the image one 20 ms cycle at a time and awaits each cycle before starting the next. Suspended Java stacks, including command coroutines, are preserved by the JavaScript Promise Integration mechanism, so blocking-looking command code stays readable without a browser-specific rewrite.
Telemetry is published through the ordinary NetworkTables API and delivered unchanged to the bundled viewer and to any AdvantageScope-compatible client attached to the same host. Everything the runtime and the host exchange is exposed on a single global namespace, which keeps the boundary explicit and prevents hidden global aliases.
flowchart LR
Host[Browser host] --> DS[Driver station and clock]
DS --> Tick[One 20 ms cycle]
Tick --> Robot[Robot lifecycle and scheduler]
Robot --> NT[In-process NetworkTables]
NT --> Viewer[Telemetry viewer]
Robot --> Tick
- Robot bases.
OpModeRobot,TimedRobot, andIterativeRobotBaseuse host-driven, HAL-free lifecycles. No native notifier, scheduler thread, or blocking competition loop is started. - Timing. Main-loop periods and
addPeriodic(...)callbacks use the host clock. Missed expirations coalesce instead of replaying a burst; periods shorter than the host interval are quantized to it. - Commands. The real WPILib scheduler and command classes remain in use. Continuation-backed coroutines preserve suspended stacks, child awaiting, cancellation, and exception identity.
- NetworkTables. Default-instance tables, typed and generic publication of primitives and arrays, raw and struct data, topic properties, schemas, and double-entry reads are supported. Native listeners, subscription queues, additional instances, and network client or server startup are not.
- AdvantageKit. Live logging is supported when the project already declares the
akit-javavendordep. File-backed recording and replay, native socket receivers, URCL, and console capture are rejected explicitly. - Hardware. Native watchdog diagnostics, controller LEDs, touchpads, CAN/FMS integration, and other JNI-backed hardware entry points fail explicitly rather than silently succeeding. Vendor code paths that require hardware must select their simulated implementation, as they would on a desktop simulation.
- Files. Files under
src/main/deployare staged into the image's in-memory filesystem before the robot is constructed and are read through the ordinaryFile, stream, reader, and read-sidejava.nio.file.FilesAPIs. The staging lives for the browser session only; writes are discarded when the page unloads.
After upgrading GraalVM, WPILib, or a framework shim, rebuild the image and re-check the staged page. Incompatible hooks and missing JSPI support surface as errors; Wasmbot does not substitute fake continuations.
Self-contained consumer projects are provided under examples/:
| Example | Demonstrates |
|---|---|
commands-v3 |
Commands V3 with an application-owned scheduler and opmodes. |
commands-v2 |
Commands V2 with TimedRobot and a default subsystem command. |
advantagekit |
Live AdvantageKit logging through NT4Publisher. |
drivetrain |
A port-zero gamepad, simulated drivetrain dynamics, and telemetry. |
Each example is an ordinary WPILib project that consumes the published plugin. From the repository root:
./gradlew check assemble publishToMavenLocal
./gradlew -p examples/commands-v3 wasmSite advantagescopeWasmbot is distributed under the MIT License; see LICENSE.
Retained WPILib-derived code is covered by WPILib-License.md, and retained AdvantageKit-derived code by AdvantageKit-LICENSE. Both are included in the distributed source bundle.
Parts of this project were developed with the assistance of large language models. All generated output was reviewed and validated in the browser before inclusion.