Inspiration

Security dashboards all look the same: charts, alert tables, log viewers, radar screens, red-and-black threat maps. They're built to be scanned, not felt. We wanted to ask a different question — what would hostile machine interaction actually look like if a computer network behaved like a living habitat instead of an observability panel?

That question became the seed of Hostile Bloom, built for an art-and-technology hackathon around the idea of making art that could not exist without technology. Its core material isn't a prerecorded animation or a static dataset — the material is interaction between machines: network rates, connection attempts, protocol probes, defensive state, and system recovery.

We deliberately avoided the usual visual language of cybersecurity products. Nothing about it was allowed to resemble a SOC dashboard, log viewer, threat map, radar screen, or observability panel. Telemetry is present in the piece, but it's subordinate to the artwork.

The core artistic idea: treat the machine as an organism. Services become exposed organs or flowers. Incoming connections behave like foreign organisms. Repeated hostile activity increases pressure within the habitat. Defensive responses act like an immune system, and recovery becomes regeneration rather than a simple "resolved" status. We use biological metaphor without pretending the system is literally alive — we're converting invisible infrastructure activity into a continuously changing visual form.

What it does

Hostile Bloom is a full-screen generative artwork driven by computer-network activity, rendered on an animated HTML Canvas over a near-black navy environment with substantial negative space. Its visual focus is a large luminous central bloom occupying roughly 35–45% of the composition.

Primary elements:

Central habitat — an irregular translucent organism with neural, coral, and mycelial qualities. Six surrounding service organisms, positioned around the habitat and connected through curved filaments: SIGNAL-TRAP SSH GHOST HTTP ECHO TELNET FILE-LURE FTP MAIL-SINK SMTP PROBE MESH Only SIGNAL-TRAP SSH currently has a real backend listener; the other five are part of the simulated ecosystem and visual architecture, not five additional completed live honeypots. We're explicit about that distinction rather than implying six live traps. Source actors — temporary hostile or interacting organisms derived from source identifiers. The reducer renders a maximum of four active source actors at once. Currents and particles — cyan/electric-blue ingress, restrained amber egress, violet neural detail, emerald recovery, and red-orange hostility. Event feed — three recent events shown on the right side, kept small so it never becomes a dashboard panel. Telemetry readout (compact, bottom-left): RX in KB/s, TX in KB/s, active sources, connections per minute, SSH probes per minute, and threat pressure from 0.00 to 1.00. On narrow screens, connections/minute and SSH probes/minute are hidden to protect the composition. Connection status, displayed bottom-center beneath LISTENING TO SYSTEM.

Design principles we held ourselves to: it must feel like computational art, not a security dashboard; values interpolate smoothly rather than jumping; hostile events change behaviour, not just text; dark negative space is intentional; animation stays performant on integrated graphics; reduced-motion preferences are respected; and the page stays full-screen with no scrolling.

Because raw events become art rather than data:

CONNECTION_OPENED creates/updates a source actor, marks the relevant service active, increases active sessions, records a connection in the rolling one-minute window, and begins a forming/approaching visual phase. SSH_PROBE marks the source hostile, pushes the service into a hostile state, records a probe in the rolling window, raises threat pressure, and increases visual intensity. DEFENSE_ACTIVATED changes the service into a defensive state — the habitat mounting an immune response. SOURCE_BLOCKED moves the source into a blocked phase, reduces threat pressure, and begins visual dissolution/isolation. CONNECTION_CLOSED closes the source/service session, begins the actor's fading phase, and allows the service to recover. REGENERATION lowers pressure and reinforces the recovery state — the habitat returning toward equilibrium. METRICS — RX controls incoming-current energy and readout; TX controls outgoing-current energy and readout; values are blended to avoid abrupt jumps.

Even without new messages, the reducer advances state every 250ms: sources fade, service activity moves toward its target, stale rates decay toward zero, one-minute event windows expire, and threat pressure gradually returns to baseline — so the piece keeps breathing on its own.

Two viewing modes

Because a public deployment can't depend on our VM being powered on, reachable, and correctly configured at the exact moment a judge opens the page, we split the experience into two explicit modes:

DEMO — Simulated ecosystem. Available publicly, requires no backend, starts the existing ~38-second generative story, uses simulated RX/TX rates and simulated sources, and is clearly labelled SIMULATION at all times. LIVE — Private machine stream. Intended for the team and controlled demonstrations. Requires an accessible WebSocket endpoint, starts with live values at zero, creates no simulated events/sources/rates, and shows CONNECTING, LIVE, RECONNECTING, or OFFLINE. If unavailable on the public deployment, it explains that private access is required rather than silently switching to Demo.

Switching modes destroys the current data source, clears its timers/socket, rebuilds reducer state, clears the event feed, resets visual source actors, and only then starts the newly selected source — simulated state never remains visible after entering Live. The chosen mode is stored in session storage so a refresh preserves it for that browser session. The Canvas keeps its subtle breathing and atmospheric movement in both modes; only the data-driven escalation waits for real events in Live, since a completely frozen Canvas would look broken rather than truthful.

Demo main story cycle (~38 seconds): CONNECTION_OPENED → SSH_PROBE → first AUTH_ATTEMPT → stronger AUTH_ATTEMPT → DEFENSE_ACTIVATED → SOURCE_BLOCKED → CONNECTION_CLOSED → REGENERATION, then a randomized pause before a new source/service begins the cycle again, plus occasional secondary sources and continuously varying simulated network rates. Demo isn't a separate fake visual implementation — the mock messages flow through the exact same reducer/renderer pipeline as real messages.

Live mode required behaviour: initial RX/TX of 0 KB/s, 0 active sources, 0 connections/minute, 0 SSH probes/minute, a low baseline threat pressure (0.06 in the current reducer), an empty event feed, no mock animation loop, no mock timers, no simulated IP addresses, and no automatic fallback to simulation. Connection states are CONNECTING, LIVE, RECONNECTING, and OFFLINE, using capped exponential reconnection with jitter; the adapter accepts text, ArrayBuffer, and Blob messages, ignores malformed payloads without crashing the artwork, and rate-limits its own console warnings. If real metrics stop arriving for more than five seconds, displayed RX/TX smoothly decay toward zero rather than being held indefinitely or replaced with simulated rates. The reducer maintains one visual source actor per src, so hundreds of probes from the same origin still represent one source — they raise event rates and pressure without pretending to be hundreds of independent attackers.

How we built it

VM backend Runtime: Ubuntu 24.04 virtual machine network interface ens33, Python 3.12 virtual environment, websockets 17.0.1. Process management: systemd service hostile-bloom.service, running as non-root user hackuser, starting after the VM network is available, restarting on failure, with hardening such as no-new-privileges and read-only filesystem protections. Signal trap — a deliberately limited, non-authenticating SSH-like service on TCP port 2222. For each connection it: derives an opaque source ID from the source address; emits CONNECTION_OPENED; returns a harmless SSH identification banner; reads at most 256 bytes with a timeout; emits SSH_PROBE if bytes are received; closes the connection; emits CONNECTION_CLOSED. It never provides a shell, authenticates a user, executes input, or stores the received payload — logs contain only the opaque source identifier and byte count, never probe contents. Source privacy — source addresses are converted into a ten-character HMAC-based identifier. The HMAC key regenerates every backend restart, so identifiers stay stable within one runtime session but can't become permanent cross-restart tracking IDs. Raw source addresses never reach the browser. Network metrics — cumulative Linux counters are read from /sys/class/net/ens33/statistics/rx_bytes and .../tx_bytes, sampled over time to calculate bytes per second; the frontend converts that to KB/s and smooths the visible values. WebSocket stream — a read-only event stream on TCP port 8765 sends connected browsers canonical JSON envelopes; browser clients are not expected to send commands back through it.

Message protocol — versioned JSON envelopes, e.g.:

json { "v": 1, "type": "EVENT", "payload": { "id": "evt-000041", "type": "SSH_PROBE", "src": "38cb4d1a57", "service": "signal-trap-ssh", "intensity": 0.52, "timestamp": "2026-08-01T12:30:45.120Z" } } json { "v": 1, "type": "METRICS", "payload": { "timestamp": "2026-08-01T12:30:45.120Z", "interface": "ens33", "rx_bytes_per_second": 145408, "tx_bytes_per_second": 38912 } }

Frontend-supported event vocabulary: CONNECTION_OPENED, SSH_PROBE, AUTH_ATTEMPT, DEFENSE_ACTIVATED, SOURCE_BLOCKED, CONNECTION_CLOSED, REGENERATION. Current live backend event vocabulary: CONNECTION_OPENED, SSH_PROBE, CONNECTION_CLOSED — defense, blocking, auth-attempt interpretation, and regeneration are supported by the frontend and used in Demo, but not yet emitted by the live backend (our top next step, below). Zod schemas validate message version, type, service, event vocabulary, intensity range, timestamps, and network values; invalid JSON, unknown events, invalid services, and out-of-range values are ignored without terminating the artwork.

Frontend Stack: React 19, TypeScript, Vite 8, TanStack Start / TanStack Router, HTML Canvas renderer, Zod for runtime validation, Tailwind CSS for interface styling. Data-source interface — BloomDataSource is the common boundary the frontend consumes: a data source publishes validated event/metrics messages, reports connection status, and can be destroyed cleanly. Two implementations exist today, MockBloomDataSource and WebSocketBloomDataSource; the mode selector chooses between them at runtime (currently gated by VITE_BLOOM_DATA_SOURCE, which is the main area we're refactoring for true runtime switching). Shared reducer — the single owner of derived application state: service activity/status, stable source actors, active-source count, rolling connections/minute, rolling SSH probes/minute, threat pressure, smoothed RX/TX, and time-based decay/recovery. The backend never decides Canvas positions, phases, colours, particle paths, or animations — that keeps visual decisions inside the artwork and the protocol independent of any one renderer. Canvas renderer — receives low-frequency snapshots of services, sources, and network pulse, while event effects are pushed directly into the Canvas; React handles textual telemetry and the event feed, and per-frame visual work stays outside React to avoid unnecessary re-renders. Stream hook — useHoneypotStream creates the configured source, subscribes to messages, feeds the reducer, updates the renderer, maintains the three-entry event feed, runs a 250ms decay tick, and destroys the source/timers on cleanup. For the mode system, it accepts the selected mode as an argument, with mode as an effect dependency so switching modes fully rebuilds the data path. Verified end to end

The deployed backend service was verified active and listening on both ports. The end-to-end smoke test connected to the WebSocket, triggered the signal trap, and successfully received canonical EVENT and METRICS messages — result: PASS: canonical EVENT and METRICS messages received. We also recorded the frontend showing LIVE SYSTEM while reacting to a real command sent to the VM.

Security and privacy model

Already implemented:

The trap runs on a non-privileged port and exposes no shell. Input is tightly limited and never stored. Raw IP addresses never reach the frontend. Source identifiers rotate on every backend restart. Incoming WebSocket messages from browsers are ignored — browsers are viewers only. Browser-bound messages are schema-validated before use. WebSocket origins are allow-listed. The backend runs as a non-root, hardened systemd service. The frontend contains no backend credentials.

Required before remote Live deployment (our near-term checklist):

Serve the WebSocket through wss:// — an HTTPS Netlify page can't open an insecure ws:// connection because browsers block mixed content. Put the WebSocket behind an authenticated TLS reverse proxy or private tunnel for team-only Live access. Add the final Netlify origin to the backend's origin allow-list. Restrict direct access to port 8765 with firewall rules once a tunnel/reverse proxy is in place. Rate-limit connection attempts so a demo can't overload the VM or browser. Never place an access token in a VITE_ environment variable, since Vite embeds those into public browser JavaScript.

Recommended short-term arrangement for the deadline: Netlify hosts the public Demo; Live stays available in the same codebase during local development over http://localhost:8080 and ws://172.16.11.128:8765; the public Live option is visibly marked private/unavailable unless a protected wss:// endpoint is configured. Any remote Live access should get a proper authenticated wss:// tunnel rather than a faked frontend password.

Deployment plan

The public URL is meant to always provide a working Demo without relying on the VM — a judge should be able to open the site, pick Demo, and immediately see the full sequence. The frontend uses TanStack Start with a Nitro/Vite build; the publish directory (expected under .output/public) needs to be confirmed from the final build rather than assumed. Non-secret build variables under consideration: VITE_BLOOM_LIVE_ENABLED=true|false and VITE_BLOOM_WS_URL=wss://protected-live-endpoint.example — these may control features or point at a public endpoint address, but must never carry a secret. Failure behaviour we're holding ourselves to: Demo keeps working if Live is unavailable; Live shows an honest connection state; Live never falls back to mock events; mode selection stays usable on desktop and mobile; and the Canvas fits the viewport without scrolling.

Challenges we ran into

Keeping "private" honest. It would have been easy to fake privacy with a hidden button or a client-side password — but frontend code ships to every visitor, so that's not privacy at all. Real privacy has to be enforced at the network/auth layer (a protected wss:// endpoint, firewalled ports, an authenticated reverse proxy), not the UI layer, and we designed the mode selector to be honest about that limitation instead of papering over it. Never letting Live lie. It was tempting to have Live mode fall back to simulated values if the socket dropped, just to keep the visuals alive. We rejected that outright: Live shows its real connection state (CONNECTING / LIVE / RECONNECTING / OFFLINE), lets stale metrics decay toward zero, and never silently substitutes fake data. HTTPS vs. ws://. A publicly hosted HTTPS page can't open an insecure WebSocket to our VM — browsers block the mixed content outright. That constraint pushed the "real" Live experience toward local/controlled demonstration unless we stand up a proper wss:// tunnel, which we scoped as deliberate follow-up work instead of rushing a fragile fix. Separating facts from art. Keeping the backend limited to raw facts (an event happened, a byte rate changed) while every color, position, and animation decision stayed in the frontend took real discipline — it was always tempting to let the backend "help" with presentation, which would have coupled the protocol to one specific visual implementation. State hygiene on mode switches. Making sure switching between Demo and Live fully tore down timers, sockets, and reducer state — so no simulated actor or stale event could ever linger into a Live session, and repeated switching couldn't spawn duplicate sockets or mock loops — needed careful cleanup logic in our streaming hook. Being precise about what's actually live. Only one of our six service organisms (SIGNAL-TRAP SSH) has a real backend listener; the other five are simulated visual entities, and only three of our seven event types (CONNECTION_OPENED, SSH_PROBE, CONNECTION_CLOSED) are currently emitted by the live backend. It was important to us to describe that distinction accurately rather than let the polish of the visuals imply more live coverage than we'd actually built.

Accomplishments that we're proud of

A genuinely working end-to-end live pipeline — not a mockup — from a real honeypot on a real VM, through a validated protocol, into a living Canvas artwork, verified with a passing smoke test and a recorded real-command-to-artwork reaction. A visual language that never once looks like a security dashboard, despite being driven entirely by security-adjacent telemetry: no charts, no alert tables, no threat map. An honest design end to end: Demo is clearly labelled as simulation and shares the real frontend pipeline; Live never fakes data it doesn't have and shows its true connection state at all times. A hardened, restart-persistent backend service with privacy-conscious, session-scoped source identifiers, built with real operational care (non-root user, systemd hardening, origin allow-listing, no stored payloads) rather than just "getting it running once." A clean architectural boundary — BloomDataSource plus a shared reducer — that let two very different data sources (a mock generator and a real WebSocket) drive the exact same rendering pipeline.

What we learned

Good data visualization design often means removing the dashboard instinct entirely — charts and tables aren't the only honest way to represent data; motion, color, and metaphor can carry meaning too, as long as the underlying mapping stays truthful. Separating "what happened" (raw facts) from "what it looks like" (interpretation) early on made the whole system easier to reason about, test, and keep honest — the backend never once needed to know what a Canvas coordinate was. Real authenticity is expensive: it's much easier to fake a live system with a looping animation than to actually keep one running, validated, and gracefully degrading — but that difference is exactly what makes the piece meaningful. Small technical constraints — like browsers blocking mixed HTTP/HTTPS content — can quietly reshape a project's entire deployment story, and it's better to plan around that honestly (private Live, public Demo) than promise a feature we couldn't yet guarantee. Privacy-by-architecture (opaque, session-scoped source identifiers; no stored payloads; read-only WebSocket) is more trustworthy than privacy-by-obscurity (a hidden button, a client-side password) — and it's worth the extra design effort even under hackathon time pressure.

What's next for Hostile Bloom

Runtime mode selector. Finish the demo | live selector described above: a minimal ModeSelector before the artwork starts, mode passed into useHoneypotStream, full teardown/reset on switch, and an OFFLINE state (never a silent mock fallback) if the Live URL is absent or rejected. Secure remote Live access. Stand up a proper authenticated wss:// endpoint (TLS reverse proxy or private tunnel) so Live mode can run for remote judges and visitors, not just on our local network — plus firewalling port 8765 and rate-limiting connection attempts. A real immune-response cycle. This is our most valuable next creative improvement. A practical backend rule would track rapid probes per source; once a threshold is crossed, temporarily rate-limit that source, emit DEFENSE_ACTIVATED, then SOURCE_BLOCKED, and finally REGENERATION after a cooldown — completing the full narrative arc the frontend already knows how to render: calm → intrusion → escalation → defense → isolation → recovery. Real listeners for the other five service organisms, so GHOST HTTP, ECHO TELNET, FILE-LURE FTP, MAIL-SINK SMTP, and PROBE MESH are backed by genuine traps instead of simulated identities. Netlify deployment and final submission assets — confirmed build output directory, non-secret environment configuration, a public Demo URL that always works even if our VM is offline, and the final project PDF/video. Beyond the hackathon: exploring the piece as a physical exhibition — a large-format display reacting to real ambient network traffic in a shared space.

Share this project:

Updates