The first time I uploaded a WebGL build to unityroom, I burned an evening on Player Settings I should have known cold. WebPublishTools is the small Editor extension I extracted while getting INVEST, a short bullet-hell game, onto the host. The window checks current WebGL Player Settings against a publish profile and lets me apply each setting from its own card, one at a time, with no global toggle.
https://github.com/tang3cko/WebPublishTools
This post is about the tool. I’ll write about the game on its own another time.
Why it exists
unityroom doesn’t accept just any WebGL build. The host has concrete requirements:
- Gzip compression — Brotli is unsupported there, so
.brfiles are rejected - The four upload files (
.loader.js,.data.gz,.framework.js.gz,.wasm.gz) from theBuildfolder - Folder and file names that must be alphanumeric only
Beyond the upload requirements, the build itself needs Player Settings tuned so that the wasm fits inside the host’s size budget. The first time I went through this checklist by hand was tedious. The second time was negligence waiting to happen.
I built the tool so the third time wouldn’t be a checklist at all.
Installation
Distributed via Git URL. Add to Packages/manifest.json:
{
"dependencies": {
"com.tang3cko.web-publish-tools": "https://github.com/tang3cko/WebPublishTools.git?path=Packages/com.tang3cko.web-publish-tools"
}
}
Open it from Window > Web Publish Tools once it’s installed.
The window layout
The window is built on UI Toolkit (UXML/USS). At the top sits a toolbar with Refresh, Build Player, Docs (which opens the active profile’s documentation URL), and a Profile dropdown anchored to the right. Below that is a short About section showing the profile name and a free-form Notes block — for unityroom this is the upload instructions quoted above. The rest of the window is a vertical scroll of cards, one per Player Setting.
The profile is persisted across launches via EditorPrefs under a single key (Tang3cko.WebPublishTools.SelectedProfile), and rebuilt on focus so the displayed values don’t drift from the actual PlayerSettings.
Cards
Each Player Setting is its own card. The window assembles them in a fixed order:
private static readonly ICheckCard[] cards = new ICheckCard[]
{
new BuildTargetCard(),
new CompressionCard(),
new DecompressionFallbackCard(),
new DevelopmentBuildCard(),
new DataCachingCard(),
new ExceptionSupportCard(),
new StrippingLevelCard(),
new ResolutionCard(),
};
ICheckCard is a single-method interface:
public interface ICheckCard
{
VisualElement Build(IPublishProfile profile, Action onChanged);
}
A card reads PlayerSettings, asks the profile what it expects, and renders one row per setting. The Compression card is representative:
public sealed class CompressionCard : ICheckCard
{
public VisualElement Build(IPublishProfile profile, Action onChanged)
{
var card = CardBuilder.CreateCard(
"Compression Format",
"Compression for the build files. unityroom requires Gzip; Brotli is unsupported there.");
var current = PlayerSettings.WebGL.compressionFormat;
card.Add(CardBuilder.CreateEnumRow(current, profile.ExpectedCompression, value =>
{
PlayerSettings.WebGL.compressionFormat = value;
AssetDatabase.SaveAssets();
onChanged?.Invoke();
}));
return card;
}
}
Each row has three possible statuses:
| Status | Icon | Meaning |
|---|---|---|
| Match | ✓ | Current matches the profile’s expected value |
| Mismatch | ✗ | Diverged — an apply button appears next to the value |
| Info | ○ | The active profile has no expectation for this setting |
The apply button only appears on Mismatch rows, so an informational row can never grow a button by accident. That distinction matters for the next section.
Why no “Apply All”
A single “Apply All” button would have been faster to write. I deliberately didn’t add one.
Development Build is the easiest example. While iterating I want it on; for an upload-ready build I want it off. With per-card apply, the row sits in Mismatch and that’s the correct state — I’m intentionally diverged from the profile, and the icon documents that. An “Apply All” would flatten that intent.
Per-setting apply also makes it harder to overwrite something by accident when switching profiles between Unity sessions.
Profiles
A profile is an interface, not a ScriptableObject. The expected values are static per platform, so binding them to assets felt unnecessary:
public interface IPublishProfile
{
string Id { get; }
string DisplayName { get; }
string DocumentationUrl { get; }
string Notes { get; }
bool RequiresWebGLBuildTarget { get; }
WebGLCompressionFormat? ExpectedCompression { get; }
bool? ExpectedDecompressionFallback { get; }
bool? ExpectedDevelopmentBuild { get; }
bool? ExpectedDataCaching { get; }
WebGLExceptionSupport? ExpectedExceptionSupport { get; }
ManagedStrippingLevel? ExpectedStrippingLevel { get; }
}
Each expectation is nullable. A profile that doesn’t have an opinion on a setting returns null, and the corresponding card falls into Info mode — current value displayed, no apply button.
The shipping UnityroomProfile is just a translation of the host’s documented requirements:
public sealed class UnityroomProfile : IPublishProfile
{
public string Id => "unityroom";
public string DisplayName => "unityroom";
public string DocumentationUrl => "https://help.unityroom.com";
public string Notes =>
"unityroom requires Gzip compression. After building, upload the four files in the Build folder " +
"(.loader.js, .data.gz, .framework.js.gz, .wasm.gz). Folder and file names must be alphanumeric only.";
public bool RequiresWebGLBuildTarget => true;
public WebGLCompressionFormat? ExpectedCompression => WebGLCompressionFormat.Gzip;
public bool? ExpectedDecompressionFallback => false;
public bool? ExpectedDevelopmentBuild => false;
public bool? ExpectedDataCaching => false;
public WebGLExceptionSupport? ExpectedExceptionSupport => WebGLExceptionSupport.None;
public ManagedStrippingLevel? ExpectedStrippingLevel => ManagedStrippingLevel.High;
}
The cards never see these constants directly — they only see the active profile via IPublishProfile. Adding a second target (itch.io, planned next) means one new class implementing the interface plus one line in PublishProfileRegistry. No card edits.
public static class PublishProfileRegistry
{
private static readonly IReadOnlyList<IPublishProfile> profiles = new IPublishProfile[]
{
new UnityroomProfile(),
};
public static IReadOnlyList<IPublishProfile> All => profiles;
public static IPublishProfile GetById(string id)
=> profiles.FirstOrDefault(p => p.Id == id) ?? profiles[0];
}
Resolution: the one card that’s different
Resolution is the one setting that doesn’t fit the “enum or bool” mould. unityroom publishes a recommended canvas size, and I expect itch.io to be similar when I get to it, so the card bundles a handful of common presets with a Custom... fallback:
private static readonly ResolutionPreset[] presets = new[]
{
new ResolutionPreset("640 x 360 (Landscape)", 640, 360),
new ResolutionPreset("854 x 480 (Landscape)", 854, 480),
new ResolutionPreset("960 x 540 (Landscape)", 960, 540),
new ResolutionPreset("1280 x 720 (Landscape)", 1280, 720),
new ResolutionPreset("1920 x 1080 (Landscape)", 1920, 1080),
new ResolutionPreset("360 x 640 (Portrait)", 360, 640),
new ResolutionPreset("540 x 960 (Portrait)", 540, 960),
new ResolutionPreset("1080 x 1920 (Portrait)", 1080, 1920),
new ResolutionPreset("720 x 720 (Square)", 720, 720),
};
Picking a preset fills Width/Height integer fields with that preset’s values; Custom... reveals the fields for manual entry. Apply writes both PlayerSettings.defaultWebScreenWidth and defaultWebScreenHeight in one step.
What’s currently supported
| Profile | Status |
|---|---|
unityroom | Supported |
itch.io | Planned |
Cards covered: Build Target, Compression Format, Decompression Fallback, Development Build, Data Caching, Exception Support, Managed Stripping Level, Resolution.
What the tool deliberately doesn’t do
The window covers PlayerSettings and the build-trigger button. Three things are deliberately out of scope.
link.xml authoring falls outside the tool because it only matters when ManagedStrippingLevel.High strips a reflection-dependent type, which is a project-specific problem rather than a profile-level one. IL2CPP code generation options and Code Optimization (LTO) are the same story: they vary by project and hardcoding them in a profile would cause more friction than they’d resolve. Host-specific runtime integration like unityroom’s scoreboard API or itch.io’s game data belongs in gameplay code, where it can talk to whatever the game already has.
Wrap-up
For me, tooling started to make sense the second time the same workflow hurt. The first time I just went through the checklist by hand and moved on. I built this on the way out of the second.
Shipping it as a Git-URL package from the start was probably a bit much for a solo tool. But the next project pulled it in from manifest.json without any file copying, so I’ll take it.