Skip to content

Adding a New Kind

This guide documents the end-to-end process for introducing a new schema-driven kind into Plantangenet at the level that plant_zine and plant_narrative currently occupy: a spec + runtime + service + walker panel + dev server stack. This is the pattern to follow when the kind is primarily driven by navigation and event flow rather than by the spatial compile pipeline used by plant_region.

For kinds that require the full forge compile graph (genome assignment, JTL generators, topological compile), see FORGE-PORT-GUIDE.md and the plant_forge overview instead.


Overview

A kind at this level consists of five artefacts that must be built in order:

1. Schema          (plantangenet-core/schemas/<kind>/v1/spec.json)
2. Example spec    (plantangenet-core/schemas/<kind>/v1/examples/<name>.yml)
3. Rust crate      (plantangenet/<kind>/src/{spec,runtime,service,bin/server}.rs)
4. Walker panel    (walker/src/panels/<kind>/ + walker/src/state/<kind>*.ts)
5. Workspace wiring (Cargo.toml membership + workspace dep alias)

Reference implementations:

Kind Schema kind string Rust crate Walker panel
Visual novel simulation.ren plant_narrative (bin: n-server) vn / VnPanel
Publication layout publication.zine plant_zine (bin: zine-server) zine / ZinePanel

Step 1 — Define the JSON Schema

Create schemas/<kind>/v1/spec.json using JSON Schema draft 2020-12.

Mandatory envelope properties every kind spec must carry:

{
  "required": ["apiVersion", "kind", "meta"],
  "properties": {
    "apiVersion": { "type": "string", "const": "net.plantange/v1" },
    "kind": { "type": "string", "const": "<namespace>.<kind>" },
    "meta": { "$ref": "../../meta/v1/meta.json" }
  }
}

kind uses dotted namespace convention. Current namespaces:

Namespace Kinds
simulation game, quest, recipe, task, region, area, feature, world, producer, item, vehicle, droid
earth animal, vegetation
social fate, league, team, turnir, zavod
publication zine

Define $defs for all structural sub-shapes. Use additionalProperties: false on all closed objects. Use relative $ref for cross-schema links. Set $id to the canonical hosted URL but validate locally with --base-uri.


Step 2 — Author an Example YAML

Create schemas/<kind>/v1/examples/<name>.yml. Every field in the example should be real and exercisable — this is the document the server will load during development.

Required frontmatter:

apiVersion: net.plantange/v1
kind: <namespace>.<kind>
<kind_id>: <machine_name>
meta:
  title: "..."
  uuid: <kind>-<name>-1

After authoring, add an entry to schemas/STATUS.md:

  • Add the new <namespace>.<kind> to the kind namespace list.
  • Add a ## <kind>/ section with a table row for the schema and example files.

Step 3 — Create the Rust Crate

3a. Cargo.toml

[package]
name = "plant_<kind>"
version = "0.1.0"
edition = "2024"

[[bin]]
name = "<kind>-server"
path = "src/bin/server.rs"

[lib]
crate-type = ["rlib"]

[dependencies]
anyhow           = { workspace = true }
serde            = { workspace = true, features = ["derive"] }
serde_json       = { workspace = true }
serde_yaml       = { workspace = true }
plant_forge      = { workspace = true }
plant_frame      = { workspace = true }
plant_runner_host = { workspace = true }
axum             = { workspace = true }
tokio            = { workspace = true, features = ["rt-multi-thread","net","macros","sync","time"] }
tokio-stream     = { version = "0.1", features = ["sync"] }
tower-http       = { workspace = true }
tracing          = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

[dev-dependencies]
tokio = { workspace = true, features = ["rt", "macros"] }

3b. spec.rs

spec.rs owns the YAML-loadable document type. It must:

  • Deserialise with serde_yaml from the canonical YAML shape.
  • Validate apiVersion and kind in the parser function.
  • Provide helper methods that the runtime needs (e.g. flatten a tree, collect all slots).
  • Expose an outcome type for session end.
/// Parse and validate a spec document from a YAML string.
pub fn parse_<kind>_spec_yaml(raw: &str) -> Result<KindSpecDocument> {
    let doc: KindSpecDocument = serde_yaml::from_str(raw)?;
    if doc.api_version != "net.plantange/v1" { return Err(...); }
    if doc.kind        != "<namespace>.<kind>" { return Err(...); }
    Ok(doc)
}

3c. runtime.rs

runtime.rs owns the stateful, synchronous, headless session driver. No async, no rendering. The Walker panel drives it via events; the service integrates it into the frame lifecycle.

Required structure:

pub enum KindSessionStatus { Idle, Active { ... }, Ended(SessionStatus) }

pub struct KindSessionRuntime {
    spec: Arc<KindSpecDocument>,
    status: KindSessionStatus,
    // ... position tracking, history, started_at
}

impl KindSessionRuntime {
    pub fn new(spec: Arc<KindSpecDocument>, ...) -> Self { ... }
    pub fn start(&mut self) -> Result<...> { ... }
    pub fn advance(&mut self) -> Result<bool> { ... }  // true = still active
    pub fn cancel(&mut self) -> Result<()> { ... }
    pub fn take_outcome(&mut self) -> Option<KindSessionOutcome> { ... }
}

3d. service.rs

service.rs owns a BTreeMap-keyed registry of runtimes and exposes the methods the server binary calls. Key pattern:

pub struct KindForgeService {
    name: String,
    sessions: BTreeMap<String, KindSessionRuntime>,
    outcomes: Vec<KindSessionOutcome>,
}

impl KindForgeService {
    pub fn load_yaml(&mut self, raw: &str) -> Result<String> { ... }
    pub fn start_session(&mut self, id: &str) -> Result<...> { ... }
    pub fn advance(&mut self, id: &str) -> Result<bool> { ... }
    pub fn cancel_session(&mut self, id: &str) -> Result<()> { ... }
    pub fn drain_outcomes(&mut self) -> Vec<KindSessionOutcome> { ... }
}

Always call drain_outcomes_for(id) immediately after any operation that can transition a session to an ended state.

3e. bin/server.rs

The binary is an Axum server following a fixed template:

const DEFAULT_BIND: &str = "127.0.0.1:<port>";  // choose an unused port

struct AppState {
    sse:               SseBus,
    service:           Arc<Mutex<KindForgeService>>,
    status:            Arc<Mutex<ServerStatus>>,
    received_outcomes: Arc<Mutex<Vec<Value>>>,
}

Required routes:

Method Path Purpose
GET /health health_ok() — Walker readiness probe
GET /state state_sse(...) — SSE event stream
POST /<kind>/load Parse and register a spec
POST /<kind>/start Start session; emit start + initial position events
POST /<kind>/advance Advance; emit position or end event
POST /<kind>/cancel Cancel; emit end event
GET /<kind>/status Query session status
POST /<kind_underscored>.outcome.v1 Receive Walker outcome (diagnostic)
GET /<kind>/received_outcomes Drain received outcomes (diagnostic)

Required SSE events (emitted via emit_and_snapshot):

Every server must emit at minimum:

  • <kind>.<object>.start.v1 — full inventory on session start
  • <kind>.navigate.v1 (or equivalent position event) — after each advance/navigate
  • <kind>.<object>.end.v1 — on completion or cancellation

Use runner_event(event_name, "turn", value) from plant_runner_host to format all SSE payloads.

main template:

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    init_tracing("<kind>_server=info,tower_http=info");
    let args: Vec<String> = std::env::args().collect();
    let (bind, _) = parse_bind_arg(&args, DEFAULT_BIND);

    let app = Arc::new(AppState { ... });
    let router = Router::new()
        .route("/health", get(health))
        .route("/state", get(state_stream))
        // ... kind routes
        .layer(CorsLayer::permissive())
        .with_state(app);

    let listener = tokio::net::TcpListener::bind(&bind).await?;
    info!("<kind>-server listening on http://{bind}");
    info!("ready");                  // Walker integration tests watch for this line
    axum::serve(listener, router).await?;
    Ok(())
}

The info!("ready") line is critical — Walker integration test fixtures use it as the startup signal.

3f. lib.rs

pub mod runtime;
pub mod service;
pub mod spec;

pub use runtime::{KindSessionRuntime, KindSessionStatus};
pub use service::KindForgeService;
pub use spec::{KindSpecDocument, KindSessionOutcome, parse_kind_spec_yaml, ...};

Step 4 — Add to the Workspace

Cargo.toml (workspace root):

# In [workspace] members:
"<kind>",

# In [workspace.dependencies]:
plant_<kind> = { path = "./<kind>" }

Then verify the crate compiles cleanly in the workspace context:

cargo check -p plant_<kind>
cargo check --bin <kind>-server

Both must finish with no errors. Warnings on unused imports should be resolved before committing.


Step 5 — Walker Panel

5a. Types (src/panels/<kind>/zineTypes.ts)

Define the TypeScript mirror of the server's SSE event payload shapes:

// Session start payload
export interface KindSessionStartEvent {
  <kind>_id: string;
  title: string;
  // ... full inventory fields
}

// Position/navigation event
export interface KindNavigateEvent {
  <kind>_id: string;
  // ... current position fields
}

// Session end event
export interface KindSessionEndEvent {
  <kind>_id: string;
  status: "completed" | "cancelled" | "skipped" | string;
  elapsed_ms?: number;
}

// Walker-side state shape
export interface KindCoreState {
  activeId?: string;
  title?: string;
  // ... current position, history, ended
}

5b. Core (src/panels/<kind>/KindCore.ts)

Synchronous state machine that consumes typed events:

export class KindCore {
  private _state: KindCoreState = { ... };
  private readonly _listeners = new Set<KindUpdateListener>();

  onUpdate(listener): () => void { ... }
  startSession(event: KindSessionStartEvent): void { ... }
  navigate(event: KindNavigateEvent): void { ... }
  endSession(event: KindSessionEndEvent): void { ... }
  reset(): void { ... }
  dispose(): void { this._listeners.clear(); }
}

5c. Host Store (src/state/kindHostStore.ts)

Zustand store that wraps the core and exposes user actions and server-apply methods. Required shape:

export interface KindHostStoreState {
  core: KindCoreState;
  state: KindHostState;
  // user-intent submissions queue
  submissions: KindSubmission[];

  // User actions (modify core + enqueue submissions)
  goNext(): { ok: boolean; error?: string };
  goPrev(): { ok: boolean; error?: string };
  goTo(id: string): { ok: boolean; error?: string };

  consumeSubmissions(): KindSubmission[];

  // Server-apply methods (called by transport lifecycle)
  applyServerSessionStart(value: unknown): void;
  applyServerNavigate(value: unknown): void;
  applyServerSessionEnd(value: unknown): void;
  reset(): void;
}

5d. Surface Store (src/state/kindSurfaceStore.ts)

Thin derived store for the panel renderer. Subscribes to the host store and re-shapes state for display:

export const kindSurfaceStore = createStore<KindSurfaceStoreState>((set, get) => ({
  // display fields
  activeId: null,
  title: null,
  status: "idle",

  // delegate actions
  goNext() { return kindHostStore.getState().goNext(); },
  // ...
}));

kindHostStore.subscribe((hostState) => {
  kindSurfaceStore.setState({ ... });
});

5e. Panel (src/panels/<kind>/KindPanel.ts)

DOM panel using the same CSS-in-JS pattern as VnPanel and ZinePanel:

export class KindPanel {
  readonly element: HTMLElement;
  private readonly _unsub: () => void;

  constructor() {
    this.element = document.createElement("div");
    injectStyles(document);
    this._unsub = kindSurfaceStore.subscribe((s) => this._render(s));
    this._render(kindSurfaceStore.getState());
  }

  private _render(state): void {
    if (state.status === "cancelled" || state.status === "completed") {
      this.element.style.display = "none";
      return;
    }
    this.element.innerHTML = `...`;
  }

  dispose(): void {
    this._unsub();
  }
}

export const kindDescriptor: PanelDescriptor = {
  id: "<kind>",
  displayName: "...",
  description: "...",
  factory(_params) {
    const panel = new KindPanel();
    return {
      element: panel.element,
      dispose() {
        panel.dispose();
      },
    };
  },
};

5f. Socket Declarations (src/panels/<kind>/kindSockets.ts)

Add any new socket type tags to src/canvas/socketTypes.ts (SocketTypeTag union), then declare the panel's socket map:

export const KIND_SOCKETS: PanelSocketMap = {
  panel_id: "<kind>_host",
  inputs: {
    session_spec: { id: "session_spec", accepts: ["<kind>_spec"], ... },
    // ...
  },
  outputs: {
    navigation_intent: { id: "navigation_intent", produces: ["<kind>_navigate"], ... },
    session_outcome:   { id: "session_outcome",   produces: ["<kind>_outcome"],  ... },
  },
};

5g. Register the panel

In the Walker app init (or dock setup):

import { kindDescriptor } from "./panels/<kind>/KindPanel.js";
registry.register(kindDescriptor);

Port Checklist

Use this before committing a new kind:

  • [ ] schemas/<kind>/v1/spec.json — valid JSON Schema, additionalProperties: false on closed objects
  • [ ] schemas/<kind>/v1/examples/<name>.yml — validates against the schema locally
  • [ ] schemas/STATUS.md — kind namespace entry + section table updated
  • [ ] zine/src/spec.rsparse_*_yaml validates apiVersion and kind
  • [ ] zine/src/runtime.rsstart, advance, cancel, take_outcome all implemented
  • [ ] zine/src/service.rsdrain_outcomes drains immediately after session ends
  • [ ] zine/src/bin/server.rs — emits info!("ready") as last startup log
  • [ ] zine/src/lib.rs — all public types re-exported
  • [ ] Cargo.toml — crate in [workspace] members and [workspace.dependencies]
  • [ ] cargo check -p plant_<kind> — clean
  • [ ] cargo check --bin <kind>-server — clean
  • [ ] Walker zineTypes.ts — TypeScript mirrors of all SSE payload shapes
  • [ ] Walker ZineCore.tsstartSession, navigate, endSession, reset, dispose
  • [ ] Walker zineHostStore.tsapplyServer* methods, submissions queue, consumeSubmissions
  • [ ] Walker zineSurfaceStore.ts — thin derived store, subscribes to host store
  • [ ] Walker ZinePanel.ts — hides on terminal states, exports PanelDescriptor
  • [ ] Walker zineSockets.ts — socket map declared; new type tags added to socketTypes.ts
  • [ ] Panel registered at app init