Are you sick of players turning your awesome Mindustry server into an NSFW gallery? Do you want your logic displays back without the fear of seeing anime girls in questionable positions?
Introducing NoHorny, your automatic NSFW moderation plugin. It detects unsafe logic displays, canvases, and pixel art made with sorters or illuminators. It can delete the buildings, refund their cost, ban the offending players, and send your moderators a Discord alert.
Enjoy this family-friendly factory building game as the cat intended it to be.
Install · Configure · How it works · Self-host · Develop · Performance · Support
Use Mindustry build 159 or later and run the server with Java 25 or later.
- Download
nohorny-plugin.jarfrom the latest release. - Put the JAR in your Mindustry server's
config/modsdirectory. - Start or restart the Mindustry server.
Look for NoHorny successfully initialized and a message that the API is operational in the server log.
When upgrading from an older release, remove nohorny-client.jar before installing the new JAR.
The plugin uses Xpdustry's public API by default, so you don't need to host a separate server or configure an account. It sends rendered images of the art to the API. See the public server's privacy page for data retention. You can also host your own API.
The default policy, BAN_NSFW, deletes art rated NSFW and bans the author by UUID and IP when NoHorny can identify one.
If you'd rather handle bans yourself, run this in the Mindustry server console:
config nohorny-auto-mod-policy DELETE_NSFW
Change settings in the Mindustry server console with config <key> <value>.
Mindustry saves the settings, and changes take effect without restarting the server.
To read a setting, omit the value. To restore its default, use default as the value.
The API gives each image a SAFE, WARN, or NSFW rating.
WARN means the classifier's score falls between the server's warning and NSFW thresholds.
Choose what the plugin does with those ratings:
config nohorny-auto-mod-policy <policy>
| Policy | Action |
|---|---|
BAN_NSFW |
Delete NSFW art and ban the author if known. This is the default. |
DELETE_NSFW |
Delete NSFW art. |
DELETE_WARN |
Delete WARN and NSFW art. |
DISABLED |
Keep classification and alerts, but leave moderation to you. |
Deletion removes the whole classified group, including linked processors for logic displays. NoHorny refunds the deleted buildings' item costs to their teams, except in infinite-resource modes.
Classifiers can make mistakes, and the author is inferred from who built or configured the art.
To review results before enabling automatic deletion or bans, use DISABLED with Discord alerts.
All plugin settings
| Key | Value | Default |
|---|---|---|
nohorny-auto-mod-policy |
One of the policies above. | BAN_NSFW |
nohorny-api-endpoint |
Classification API URL, including /api. |
https://nohorny.xpdustry.com/api |
nohorny-api-auth-type |
DISABLED, BASIC, or BEARER. |
DISABLED |
nohorny-api-auth-value |
username:password for BASIC, or the raw token for BEARER. |
null |
nohorny-discord-webhook |
Discord webhook URL for WARN and NSFW alerts. |
null |
nohorny-discord-webhook-name |
Override the webhook's username. | null |
nohorny-discord-webhook-proxy |
Use a proxy for Discord requests. | false |
nohorny-discord-webhook-image |
AUTO, ALWAYS, or NEVER. |
AUTO |
nohorny-debug-tap |
Let admins double-tap tracked art to inspect it. | false |
null means unset. For example, config nohorny-discord-webhook null stops Discord alerts.
Setting nohorny-api-endpoint to null stops classification.
Create a Discord webhook in the channel your moderators use, then set its URL:
config nohorny-discord-webhook https://discord.com/api/webhooks/999999/abcdefgh
Alerts include the rating, location, and author when known.
If the API provides a request page URL, the alert has a View request button for inspecting the result.
Alerts work with every moderation policy, including DISABLED.
Choose when to attach the image with config nohorny-discord-webhook-image <policy>:
| Policy | Image upload |
|---|---|
AUTO |
Attach the image only when there is no request page URL. This is the default. |
ALWAYS |
Attach the image and include the request page link if available. |
NEVER |
Include the request page link if available, without attaching the image. |
If your host cannot reach Discord, config nohorny-discord-webhook-proxy true enables a public proxy from ProxyScrape.
For automatic deletion of old alerts, see MAD.
Enable config nohorny-debug-tap true, then double-tap a tracked display, canvas, sorter, or illuminator as an in-game admin.
NoHorny labels the group and saves its rendered image in config/mods/nohorny/debug/.
This lets you see which buildings NoHorny groups together and what image the classifier receives.
The Mindustry plugin tracks building placement and configuration changes, then groups nearby art into images. Logic displays are rendered from their processors' draw instructions, canvases from their pixel data, and sorter or illuminator art from the blocks' colors. NoHorny renders these images on the server without taking screenshots of the game.
After a short wait for construction to finish, the plugin renders a group and sends it as a JPEG to the classification API.
The API returns a rating and score. The plugin then fires a ClassificationEvent, which the moderator and Discord alerts use.
Rendering and API requests run on background threads. The event fires on Mindustry's main thread, where another plugin can handle the result.
The wait and the API request mean moderation takes time. Art can be visible before NoHorny removes it. Sorter and illuminator groups are ignored unless they span at least 8 tiles in both dimensions.
The code is split into these modules:
| Module | Responsibility |
|---|---|
nohorny-plugin |
Track buildings, render art, call the API, and moderate the game. |
nohorny-common |
Shared image types, geometry, and API response types. |
nohorny-server |
Classification API, accounts, rate limits, storage, and request history. |
nohorny-native |
Run ONNX models with OpenCV, called through Java's Foreign Function and Memory API. |
nohorny-frontend |
Solid 2 and Tailwind CSS app for statistics, request pages, and the admin panel. |
For the tracking code, start with NoHornyEventBus and GroupCollector.
For classification, start with ClassificationService.
Self-hosting lets you choose the classifier, manage API accounts, and control data retention. The standalone server includes the API, request pages, statistics, and an admin panel.
Use Java 25 or later. The release JAR includes native libraries for Linux on x86_64 and ARM64, Windows on x86_64, and macOS on ARM64.
-
Download
nohorny-server.jarfrom the latest release. -
Run
java -jar nohorny-server.jar. -
Open http://127.0.0.1:8080 to see the classification counters.
-
If Mindustry runs on the same machine, set the plugin's endpoint in its server console:
config nohorny-api-endpoint http://127.0.0.1:8080/api
The API listens on 127.0.0.1:8080 by default.
For a remote plugin, put it behind a reverse proxy and use an endpoint such as https://nohorny.example.com/api.
Keep /api at the end of the plugin's endpoint.
The default ViT classifier downloads its ONNX model from Hugging Face on the first start and caches it in .cached-models.
Allow that download to finish before connecting the plugin.
Run with Docker
The published Docker image runs on Linux x86_64. Start it with:
docker run -d --name nohorny-server \
-p 127.0.0.1:8080:8080 \
-e SERVER_ADDRESS=0.0.0.0 \
-v nohorny-data:/data \
ghcr.io/xpdustry/nohorny-server:4.0.0-beta.11SERVER_ADDRESS lets the app accept traffic through Docker's port mapping.
The mapping above keeps the port accessible only from the host.
The /data volume keeps the database and images when you replace the container.
See application.yaml for all defaults.
Override settings with an application.yaml file in the working directory, environment variables, or JVM properties.
For example, SERVER_PORT=9090 java -jar nohorny-server.jar changes the port.
Set nohorny.public-url to the public address, such as https://nohorny.example.com, to include request page links in API responses and Discord alerts.
Use the address without /api here.
Set nohorny.sentry.dsn (or NOHORNY_SENTRY_DSN) to report the errors of the web pages to your Sentry project.
Only the errors are reported, without cookies, user data, request identifiers or usernames.
Choose a classifier
nohorny.classifier.type accepts vit, sight-engine, or an ordered list such as [vit, sight-engine].
With a list, the next classifier runs only if the previous one rates the image NSFW.
This lets the local model filter out safe images before you pay for a SightEngine request.
The default ViT model rates scores below 0.6 as SAFE, scores from 0.6 to below 0.9 as WARN, and scores of 0.9 or more as NSFW.
Adjust thresholds.warn and thresholds.nsfw under nohorny.classifier.vit to change those boundaries.
For another ONNX model, change the Hugging Face repository, revision, and file, or set nohorny.classifier.vit.source.type to local and provide local.path.
The configured labels must match the model's output order, and nsfw-label must name its NSFW label.
See download-and-convert-model.sh for conversion instructions.
To use SightEngine, set SIGHT_ENGINE_USER and SIGHT_ENGINE_SECRET and include sight-engine in the classifier list.
Create admin and API accounts
Set NOHORNY_SECURITY_ADMIN_PASSWORD before starting the server to create the initial admin account.
Its username is admin, or the value of NOHORNY_SECURITY_ADMIN_USERNAME.
Without a password, the server creates no initial admin.
Sign in at /admin and open Users to create accounts and manage their passwords, admin access, and rate limits.
Accounts without admin access can still authenticate to the classification API.
The configured initial admin's password and admin role are restored on each start, so change its password in the server configuration.
To connect the plugin with an API account, run these commands in the Mindustry server console:
config nohorny-api-auth-value username:password
config nohorny-api-auth-type BASIC
The standalone server uses HTTP Basic authentication.
The plugin's BEARER option is for other APIs that accept tokens.
Request pages, storage, and retention
| Path | Purpose |
|---|---|
/ |
Live classification counters. |
/requests/{id} |
Result details and an image that stays blurred until revealed. |
/privacy |
Data collection and retention information. |
/admin |
Request review, account management and requester mappings for admins. |
/api |
Base path for the API. |
Request pages are public to anyone with the link, including the option to delete the image.
The server stores submitted images for every rating, including SAFE.
| Data | Setting | Default |
|---|---|---|
| Database file | nohorny.storage.database |
database.sqlite |
| Image directory | nohorny.storage.images |
images |
| Image retention | nohorny.requests.image-retention |
14 days |
| Request retention | nohorny.requests.retention |
90 days |
File paths are relative to the working directory. The Docker image stores the database and images under /data.
All-time classification counters remain after images and requests expire.
Rate limits and reverse proxies
The API's default limits are configured under nohorny.rate-limit:
| Caller | Requests per minute | Shared by | Setting |
|---|---|---|---|
| Anonymous client | 5 | One client address | anonymous |
| Listed Mindustry server without an account | 60 | Servers in the same listed network | mindustry |
| Account | 120 for new accounts | All addresses that use the account | user |
user sets the default for new accounts. Change an existing account's limit in the admin panel.
Local addresses (loopback, private and link-local, such as a Docker network) use the anonymous limit unless they authenticate with an account.
Open Settings in the admin panel to map address ranges to a Mindustry network, an account, Local or Anonymous, such as 172.18.0.0/16 to your own network.
A mapping applies to requests without an account, the most specific range wins, and Apply relabels the retained requests of its range.
Set anonymous or mindustry to 0 to require an account for those callers.
When rate-limited, the plugin waits and retries, up to three attempts per classification.
For a reverse proxy, forward the whole host, including the pages and /api, and set:
server:
forward-headers-strategy: frameworkThe proxy must overwrite Forwarded and X-Forwarded-* headers so the server sees the client's address for rate limits.
Keep the default none if clients connect directly.
Use a JDK 25 or later. The plugin build needs only the JDK. To build the server, also install Node.js 22.12 or later, pnpm 12, CMake, and a C++20 compiler.
Run these commands from the repository root:
| Task | Command | Output |
|---|---|---|
| Build the plugin | ./gradlew :nohorny-plugin:shadowJar |
nohorny-plugin/build/libs/nohorny-plugin.jar |
| Build the server | ./gradlew :nohorny-server:bootJar |
nohorny-server/build/libs/nohorny-server.jar |
| Run Mindustry with the plugin | ./gradlew :nohorny-plugin:runMindustryServer |
Local sandbox server. |
| Build the native classifier | ./gradlew :nohorny-native:cmakeBuild |
Native OpenCV library for your platform. |
| Check the frontend | ./gradlew :nohorny-frontend:check |
Type and lint checks. |
| Format Java and Gradle files | ./gradlew spotlessApply |
Formatted code and license headers. |
The server build compiles and bundles the native classifier and frontend.
The first native build takes longer because it also compiles OpenCV.
Run the resulting server JAR with java -jar nohorny-server/build/libs/nohorny-server.jar.
For frontend work, start the Java server at 127.0.0.1:8080, then run:
cd nohorny-frontend
pnpm install
pnpm devVite proxies API and login requests to the Java server.
Set NOHORNY_BACKEND to use a different backend address.
To build the Docker image locally, run docker build -t nohorny-server ..
The build handles Java, native libraries, and the frontend in separate stages.
Add these dependencies to your build.gradle.kts, using the version of NoHorny installed on the server:
repositories {
maven { url = uri("https://maven.xpdustry.com/releases") }
}
dependencies {
compileOnly("com.xpdustry:nohorny-common:4.0.0-beta.11")
compileOnly("com.xpdustry:nohorny-plugin:4.0.0-beta.11")
}Declare "dependencies": ["nohorny"] in your plugin's plugin.json or mod.json so Mindustry loads NoHorny as a dependency.
Install NoHorny alongside your plugin, and keep its classes out of your plugin JAR.
Up to 4.0.0-beta.10, the artifact was named nohorny-client. The Java package remains com.xpdustry.nohorny.client.
Subscribe to ClassificationEvent to receive the classified group, nullable author, and ClassificationResponse:
import arc.Events;
import arc.util.Log;
import com.xpdustry.nohorny.client.ClassificationEvent;
import mindustry.mod.Plugin;
public final class MyPlugin extends Plugin {
@Override
public void init() {
Events.on(ClassificationEvent.class, event -> {
Log.info("Art at (@, @) was rated @",
event.group().x(), event.group().y(), event.response().rating());
});
}
}The handler runs on Mindustry's main thread. event.author() is null when NoHorny cannot identify an author.
The response includes the classifier name, rating, score, request identifier, and optional request page URL.
Use NoHornySetting to change the same settings available in the console.
For custom moderation, call NoHornySetting.AUTO_MOD_POLICY.set(AutoModeratorPolicy.DISABLED) in init() to disable the built-in moderator.
Import com.xpdustry.nohorny.client.NoHornySetting and com.xpdustry.nohorny.client.AutoModeratorPolicy for that call.
You can use the classification API without Mindustry. Send the JPEG bytes as the request body:
curl --fail-with-body \
-H 'Content-Type: image/jpeg' \
--data-binary @art.jpg \
http://127.0.0.1:8080/api/classifyAdd --user username:password if you use an API account.
The JSON response has the fields in ClassificationResponse. url is optional and requires the server's nohorny.public-url setting.
Errors return a JSON message. A rate-limited request returns HTTP 429 with a Retry-After header in seconds.
For a health check, use GET /api/status.
The benchmark puts NoHorny through a deliberate worst-case load: maps packed with art and up to 100 art changes every tick. The largest map is 500×500 tiles with about 150,000 buildings. At 60 TPS, 100 changes per tick means 6,000 art changes a second, continuously.
The benchmark advances the game clock and accepts every group instantly, so the trackers keep grouping art without waiting for the API.
Even under these conditions, NoHorny stays light on the benchmark machine:
- With no art changes, mean tracking overhead is below 0.05 µs per tick, even on the map packed with 150,000 buildings.
- At 10 changes per tick, the highest mean overhead is 0.14 ms, about 0.9% of a tick's 16.7 ms budget at 60 TPS.
- At 100 changes per tick, the highest mean overhead is 1.07 ms, still under 7% of a tick across every tested map and garbage collector.
- On the largest map, tracking adds 52 to 56 MiB of heap, about 16% on top of the map's own memory use.
At 100 changes per tick, Mindustry itself spends roughly 4 to 5.5 times as much applying the changes as NoHorny spends tracking them. G1 and Serial give comparable results.
The grouping also has a fixed amount of work per tick, so a huge piece of art or a flood of queued buildings is spread over several ticks instead of stalling one.
These measurements cover tracking and grouping on Mindustry's main thread. Image rendering and API requests run on background threads.
Benchmark conditions and results
The recorded run used Xpdustry's Xeon E5-1650 v4 server, Java 25, a 2 GiB heap, and G1 and Serial garbage collectors. The 100×100, 250×250, and 500×500 maps were filled with canvases, logic displays, sorters, and illuminators. Each large display had six processors with 100 draw instructions each. Each tick applied 0, 10, or 100 random art changes.
The table shows mean main-thread time per tick. The Mindustry columns measure the game's own cost to apply the changes. The NoHorny columns show the extra cost of tracking and grouping them.
| Map | Art changes per tick | Mindustry, G1 | NoHorny, G1 | Mindustry, Serial | NoHorny, Serial |
|---|---|---|---|---|---|
| 100×100, 6k buildings | 0 | ~0 µs | +0.02 µs | ~0 µs | +0.04 µs |
| 250×250, 38k buildings | 0 | ~0 µs | +0.03 µs | ~0 µs | +0.04 µs |
| 500×500, 150k buildings | 0 | ~0 µs | +0.02 µs | ~0 µs | +0.04 µs |
| 100×100 | 10 | 395 µs | +77 µs | 367 µs | +68 µs |
| 250×250 | 10 | 412 µs | +98 µs | 429 µs | +104 µs |
| 500×500 | 10 | 427 µs | +114 µs | 480 µs | +144 µs |
| 100×100 | 100 | 3.83 ms | +0.71 ms | 3.57 ms | +0.87 ms |
| 250×250 | 100 | 4.16 ms | +0.94 ms | 4.18 ms | +1.00 ms |
| 500×500 | 100 | 4.12 ms | +0.91 ms | 4.77 ms | +1.07 ms |
The next table shows the initial map scan. Heap use was measured after a full garbage collection in the run with 100 changes per tick.
| Map | Scan, G1 | Scan, Serial | Map heap | NoHorny heap |
|---|---|---|---|---|
| 100×100 | 3.8 ms | 2.4 ms | 25 MiB | +2.5 to 2.7 MiB |
| 250×250 | 27 ms | 21 ms | 94 MiB | +14.1 to 14.3 MiB |
| 500×500 | 114 ms | 92 ms | 340 MiB | +52 to 56 MiB |
To reproduce the measurements, run the JMH benchmarks:
./gradlew :nohorny-plugin:jmhFullThis uses 3 forks with G1 and Serial and takes about an hour.
For a shorter run with one fork and G1, use ./gradlew :nohorny-plugin:jmh, which takes about 10 minutes.
Results are written to nohorny-plugin/build/jmh/jmhFull.json or jmh.json.
For help with setup or unexpected results, ask in #support on the Xpdustry Discord server.
For a bug report, include your Mindustry and NoHorny versions, relevant logs, and a request identifier or link if you have one.
NoHorny is available under the MIT license.
