Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Diagrams — draw a system, then watch it work

Declare a technical diagram as text and manic animates it — architecture diagrams today, with flows, sequences, and more to come. Declare what belongs where, connect the topology, then follow one message through the design. Manic handles nested responsive layout, provider artwork, parallel lanes, and deterministic motion; ordinary step, show, draw, flow, camera, and text verbs remain the presentation language.

Unlike static diagram-as-code (Mermaid, Mingrammer), a manic diagram moves: the same source that draws the boxes also carries a request through them.

The vocabulary

The whole kit is nine words — a behaviour layer over diagram structure, not a catalogue of cloud verbs:

architecture  cluster  node  connect  link
message       route    hotpath         flow

With them you get provider-neutral nodes (no assets) or real icons from 17 providers; declaration-first nested clusters with responsive layout; direct, curved, and orthogonal connections with node-boundary ports; cold dashed topology kept separate from solid runtime motion; and one persistent message whose identity survives every hop. The kit shines for one clear story — and density is no longer your job: geometry is optional, so even a dense platform overview auto-fits and scales itself to stay in-frame (see It just fits, below).

Possible vs. actual

The one rule to internalise: a connection states what is possible; a moving message states what happened.

connect(toWorker1, queue, worker1, orthogonal, right, left);
connect(toWorker2, queue, worker2);
connect(toWorker3, queue, worker3, orthogonal, right, left);

message(job, queue, "101");
route(job, toWorker2, 0.9, linear);
route(job, worker2ToDatabase, 0.9, linear);

The three connections state what is possible. Only the two explicit route calls state what happened. Manic does not claim RabbitMQ selected Worker 2; the creator authored that selection.

The mental model

LayerWordsMeaning
Ownershiparchitecture, cluster, nodewhat belongs where
Topologyconnect, linkpossible directed paths and neutral visual relationships
Runtime storymessage, route, hotpathwhat one persistent message actually does
Aggregate activityflowuntracked traffic over one path or a path group

request is an HTTP-friendly alias of message. Provider services are data, not vocabulary: use "aws:lambda", not a separate Lambda constructor.

Provider names are visual metadata only. Manic does not infer balancing, queueing, broadcasting, retries, or any other behavior from an icon or label. Use explicit route/travel for one authored journey, seq for authored order, and par plus multiple objects when several journeys should happen together. hotpath is only an optional seeded walk over declared graph geometry; it is not a simulation of the services shown.

It just fits — no coordinates

Geometry is optional. Write architecture(id) with no center or size and the diagram fills the canvas (leaving the title and caption bands clear), centres itself, and lays out every cluster and node for you:

architecture(platform);   // that's the whole canvas declaration

Then add as much as the story needs. Leaf clusters with many children wrap into a grid, top-level tiers wrap into rows, and when the packed content would still overflow the frame, the whole diagram scales down as one — cards, icons, labels, and every connection lane together — to a legible minimum. Nothing clips off-canvas, and you never touch a coordinate, split a cluster into columns, or shrink a font to make it fit.

This example declares Route 53, an edge load balancer, a gateway, three availability zones of services, a replicated database cluster, and monitoring — all with zero geometry. Add a tier or a zone and it simply re-fits:

title("A Microservices Platform — Laid Out Automatically");
canvas("16:9");
template("neon");

let u = (w+h-abs(w-h))/1440;
watermark(mark, (w*0.15, h*0.04), "Made With Manic");
text(kicker, (cx, h*0.055), "DIAGRAMS · AUTO-FIT · NO COORDINATES");
text(headline, (cx, h*0.12), "Add a tier. It re-fits itself.");
text(caption, (cx, h*0.95), "DNS, load balancing, three AZs of services, a database cluster, and monitoring — all auto-laid.");
size(kicker, 15*u); color(kicker, dim); bold(kicker);
size(headline, 26*u); bold(headline); wrap(headline, w*0.86);
size(caption, 17*u); color(caption, dim); wrap(caption, w*0.86);

// No geometry: the diagram auto-fits. Route 53, an edge load balancer, three
// availability zones, a replicated database cluster and monitoring all reflow
// to fit — and when the content would overflow, the whole diagram scales down
// as one. The author types no coordinate.
architecture(platform);

node(dns, platform, "aws:route53", "route 53");
node(lb, platform, "aws:elb", "load balancer");
node(gateway, platform, "aws:api-gateway", "gateway");

cluster(region, platform, "REGION us-east-1");
cluster(azA, region, "AZ-A");
node(a1, azA, "aws:ecs", "svc"); node(a2, azA, "aws:ecs", "svc"); node(a3, azA, "aws:ecs", "svc");
cluster(azB, region, "AZ-B");
node(b1, azB, "aws:ecs", "svc"); node(b2, azB, "aws:ecs", "svc"); node(b3, azB, "aws:ecs", "svc");
cluster(azC, region, "AZ-C");
node(c1, azC, "aws:ecs", "svc"); node(c2, azC, "aws:ecs", "svc"); node(c3, azC, "aws:ecs", "svc");

cluster(database, platform, "DATABASE CLUSTER");
node(primary, database, "aws:rds", "primary");
node(replica1, database, "aws:rds", "replica");
node(replica2, database, "aws:rds", "replica");
node(cache, database, "aws:elasticache", "cache");

cluster(observability, platform, "MONITORING");
node(metrics, observability, "aws:cloudwatch", "metrics");
node(logs, observability, "aws:cloudwatch", "logs");
node(alarms, observability, "aws:cloudwatch", "alarms");

connect(dnsToLb, dns, lb);        // resolve, then hit the edge load balancer
connect(lbToGw, lb, gateway);     // balanced across the API gateway
connect(toA, gateway, azA);       // gateway fans out to each zone's services
connect(toB, gateway, azB);
connect(toC, gateway, azC);
connect(toWrite, a1, primary);    // a representative service writes the primary
connect(toRead, a1, replica1);    // reads served from a replica
connect(toCache, a1, cache);
connect(repl1, primary, replica1); // primary replicates to both replicas
connect(repl2, primary, replica2);
connect(toMetrics, b1, metrics);  // services emit telemetry to monitoring

color(dnsToLb, cyan);   color(dnsToLb.hot, cyan);      // request path
color(lbToGw, cyan);    color(lbToGw.hot, cyan);
color(toA, cyan);       color(toA.hot, cyan);
color(toB, cyan);       color(toB.hot, cyan);
color(toC, cyan);       color(toC.hot, cyan);
color(toRead, cyan);    color(toRead.hot, cyan);
color(toCache, cyan);   color(toCache.hot, cyan);
color(toWrite, magenta); color(toWrite.hot, magenta);  // write path
color(repl1, lime);     color(repl1.hot, lime);         // replication
color(repl2, lime);     color(repl2.hot, lime);
color(toMetrics, gold); color(toMetrics.hot, gold);     // telemetry

request(call, dns, "GET /order");

hidden(platform.nodes);
hidden(region.parts); hidden(azA.parts); hidden(azB.parts); hidden(azC.parts);
hidden(database.parts); hidden(observability.parts);
hidden(call.parts);
untraced(dnsToLb); untraced(lbToGw);
untraced(toA); untraced(toB); untraced(toC);
untraced(toWrite); untraced(toRead); untraced(toCache);
untraced(repl1); untraced(repl2); untraced(toMetrics);

step("edge") {
  par {
    stagger(0.08) { show(dns, 0.4); show(lb, 0.4); show(gateway, 0.4); }
    say(caption, "Route 53 resolves the name, an edge load balancer spreads the traffic, the gateway takes it from there.", 0.4);
  }
}
wait(0.2);

step("reveal-zones") {
  par {
    show(region.parts, 0.4);
    show(azA.parts, 0.4); stagger(0.05) { show(a1,0.3); show(a2,0.3); show(a3,0.3); }
    show(azB.parts, 0.4); stagger(0.05) { show(b1,0.3); show(b2,0.3); show(b3,0.3); }
    show(azC.parts, 0.4); stagger(0.05) { show(c1,0.3); show(c2,0.3); show(c3,0.3); }
    say(caption, "Three availability zones, each with its own services — nested clusters, auto-laid.", 0.4);
  }
}
wait(0.2);

step("state-and-monitoring") {
  par {
    show(database.parts, 0.4); show(primary, 0.4); show(replica1, 0.4); show(replica2, 0.4); show(cache, 0.4);
    show(observability.parts, 0.4); show(metrics, 0.4); show(logs, 0.4); show(alarms, 0.4);
    say(caption, "A replicated database cluster and monitoring join in — the whole diagram re-fits.", 0.4);
  }
}
wait(0.25);

step("topology") {
  par {
    stagger(0.06) {
      draw(dnsToLb,0.4); draw(lbToGw,0.4);
      draw(toA,0.4); draw(toB,0.4); draw(toC,0.4);
      draw(toWrite,0.4); draw(toRead,0.4); draw(toCache,0.4);
      draw(repl1,0.4); draw(repl2,0.4); draw(toMetrics,0.4);
    }
    say(caption, "Paths coloured by relationship: request cyan, writes magenta, replication lime, telemetry gold.", 0.4);
  }
}
wait(0.3);

step("one-request") {
  par {
    seq {
      show(call.parts, 0.2);
      route(call, dnsToLb, 0.7, smooth);
      route(call, lbToGw, 0.7, smooth);
      route(call, toA, 0.7, smooth);
      route(call, toWrite, 0.7, smooth);
    }
    say(caption, "One order: Route 53 → load balancer → gateway → an AZ-A service → the primary database.", 0.4);
  }
}
wait(0.3);

step("takeaway") {
  par {
    pulse(region.parts, 0.7);
    pulse(database.parts, 0.7);
    say(caption, "Add a tier, a zone, a replica — you never touch a coordinate. That is manic.", 0.45);
  }
}
wait(1.4);

Pass explicit (center, width, height) only when you deliberately want a hand-placed diagram; it is never required to make one fit.

Flowcharts — a diagram that runs

An architecture lays out regions and clusters; a flowchart ranks its nodes by the edges between them (like Mermaid’s graph TD/LR) and — this is manic’s edge — it runs: a token walks the process and takes a branch. Just declare flowchart(id): like an architecture it auto-fits and needs no coordinates. It lays the flow top-down and, when the flow is long, wraps it into side-by-side columns — the count chosen to fill the frame — so every node stays full-size and readable: you read down one column, then across to the top of the next, like a multi-column diagram on paper. Forward edges are clean elbows; a long feedback loop routes neatly around the bottom-left perimeter instead of cutting across the middle. Pass flowchart(id, LR) to force a single left-to-right row instead.

A flowchart is only useful if you can read its nodes, so there is a readability limit — 26 nodes top-down, 14 left-right — and past it (more than even column wrapping keeps legible) the editor warns you to split the process into linked sub-flows: end one chart with a connector node that hands off to the next. Raise the limit with a third argument, flowchart(id, dir, N), when you deliberately want a denser chart.

Every node is the same node(id, parent, "kind", "label") you already know — only now the kind is a shape: terminator (a start/end pill), process (a step rectangle), decision (a diamond), io (a parallelogram), subprocess, or connector (a small circle). The shape is the node’s body, with the label centred inside — nothing new to learn beyond the shape names.

Connect the nodes; inside a flowchart the edges default to clean orthogonal elbow connectors along the rank direction. Give a decision’s branches captions with annotate(edge, "yes"), colour them by meaning, and route a token from the start terminator to watch the algorithm execute. A loop is just an edge back to an earlier node (give it explicit ports so it routes up the side):

title("A Flowchart That Runs — the Factorial Loop");
canvas("16:9");
template("paper");

let u = (w+h-abs(w-h))/1440;
watermark(mark, (w*0.15, h*0.04), "Made With Manic");
text(kicker, (cx, h*0.06), "DIAGRAMS · FLOWCHART · AUTO-LAID");
text(headline, (cx, h*0.12), "A flowchart that runs");
size(kicker, 15*u); color(kicker, dim); bold(kicker);
size(headline, 26*u); bold(headline); wrap(headline, w*0.86);

// `flowchart(fc)` ranks nodes by their connections, auto-fits, and auto-orients:
// a shallow flow like this stays top-down on its own. No coordinates, no direction
// to choose. Node shapes are string kinds.
flowchart(fc);
node(start, fc, "terminator", "start");
node(rd, fc, "io", "read n");
node(init, fc, "process", "f=1  i=1");
node(dec, fc, "decision", "i <= n?");
node(body, fc, "process", "f=f*i  i=i+1");
node(out, fc, "io", "print f");
node(fin, fc, "terminator", "end");

connect(e1, start, rd);
connect(e2, rd, init);
connect(e3, init, dec);
connect(e4, dec, body);                          // keep looping
connect(e5, dec, out);                           // exit
connect(e6, body, dec, orthogonal, right, right); // loop back up the side
connect(e7, out, fin);

color(e4, cyan); color(e4.hot, cyan);
color(e5, lime); color(e5.hot, lime);
color(e6, gold); color(e6.hot, gold);
annotate(e4, "yes"); annotate(e5, "no"); annotate(e6, "loop");

// A colour key stands in for narration — the diagram explains itself.
text(k1, (w*0.34, h*0.93), "— continue"); size(k1, 14*u); color(k1, cyan); bold(k1);
text(k2, (w*0.50, h*0.93), "— exit");     size(k2, 14*u); color(k2, lime); bold(k2);
text(k3, (w*0.66, h*0.93), "— loop back"); size(k3, 14*u); color(k3, gold); bold(k3);

request(tok, start, "n = 3");
hidden(fc.nodes);
hidden(tok.parts);

step("shape") {
  stagger(0.07) {
    show(start, 0.3); show(rd, 0.3); show(init, 0.3); show(dec, 0.3);
    show(body, 0.3); show(out, 0.3); show(fin, 0.3);
  }
}
wait(0.2);

step("run") {
  seq {
    show(tok.parts, 0.2);
    route(tok, e1, 0.4, smooth); route(tok, e2, 0.4, smooth); route(tok, e3, 0.4, smooth);
    route(tok, e4, 0.4, smooth); route(tok, e6, 0.5, smooth);
    route(tok, e4, 0.4, smooth); route(tok, e6, 0.5, smooth);
    route(tok, e4, 0.4, smooth); route(tok, e6, 0.5, smooth);
    route(tok, e5, 0.4, smooth); route(tok, e7, 0.4, smooth);
  }
}
wait(0.3);

step("takeaway") {
  seq { pulse(fin, 0.7); pulse(fin, 0.7); }
}
wait(1.0);

Start without a cloud provider

The foundation includes native visual archetypes for client, service, gateway, database, cache, queue, storage, and external. These names select a compact visual only; they still imply no runtime behavior:

architecture(platform, (cx,cy), w*0.88, h*0.58);
node(user, platform, "client", "User");
node(edge, platform, "gateway", "Gateway");
node(api, platform, "service", "API Service");
node(db, platform, "database", "Database");

Use provider names only when recognizable artwork helps the lesson. A generic diagram remains fully portable and needs no image assets.

The complete foundation story sends one request forward and returns the same identity over separately directed paths:

title("Systems Foundation — One Request, One Return");
canvas("16:9");
template("mono");

watermark(mark, (160, 54), "Made With Manic");
text(kicker, (cx, 54), "PROVIDER-NEUTRAL SYSTEMS");
text(headline, (cx, 104), "Structure is not behaviour");
text(caption, (cx, h-58), "Cold paths show what is possible. One persistent dot shows what actually happened.");
size(kicker, 18); color(kicker, dim); bold(kicker);
size(headline, 34); bold(headline);
size(caption, 20); color(caption, dim); wrap(caption, w*0.86);

architecture(platform, (cx, cy+15), w*0.88, h*0.58);
node(user, platform, "client", "User");
node(edge, platform, "gateway", "Gateway");
node(api, platform, "service", "API Service");
node(cache, platform, "cache", "Cache");
node(db, platform, "database", "Database");

connect(toEdge, user, edge);
connect(toApi, edge, api);
connect(toCache, api, cache);
// One semantic connection: Manic chooses node-boundary ports and keeps the
// packet continuous across every internal orthogonal segment.
connect(toDb, cache, db, orthogonal, right, top);
connect(dbReturn, db, api, 95);
connect(apiReturn, api, edge, 75);
connect(edgeReturn, edge, user, 55);

message(packet, user, "GET");
hidden(kicker); hidden(headline); hidden(caption);
untraced(platform.connections);
hidden(packet);

step("structure") {
  par {
    show(kicker, 0.35);
    show(headline, 0.45);
    show(caption, 0.45);
    draw(platform.connections, 1.20, smooth);
  }
}
wait(0.45);

step("continuous-round-trip") {
  par {
    show(packet, 0.20);
    say(caption, "One dot moves continuously end to end. Every route hand-off preserves its identity.", 0.35);
    seq {
      route(packet, toEdge, 0.70, linear);
      route(packet, toApi, 0.70, linear);
      route(packet, toCache, 0.70, linear);
      route(packet, toDb, 0.70, linear);
      par {
        recolor(packet, magenta, 0.30);
        route(packet, dbReturn, 0.80, linear);
      }
      route(packet, apiReturn, 0.75, linear);
      route(packet, edgeReturn, 0.75, linear);
    }
  }
}
wait(0.45);

step("proof") {
  par {
    pulse(packet, 0.70);
    say(caption, "The same dot is back at the User: one identity, seven connected journeys, zero animation gaps.", 0.40);
  }
}
wait(1.10);

Declare the parent first

A node’s second argument is its parent architecture or cluster. Clusters may themselves belong to an earlier cluster, giving hierarchy without member lists or special block syntax:

architecture(events, (cx,cy), w*0.9, h*0.7);

node(source, events, "aws:eks", "K8s Source");
cluster(flows, events, "EVENT FLOWS");

cluster(workers, flows, "EVENT WORKERS");
node(worker1, workers, "aws:ecs", "Worker 1");
node(worker2, workers, "aws:ecs", "Worker 2");
node(worker3, workers, "aws:ecs", "Worker 3");

node(queue, flows, "aws:sqs", "Event Queue");

cluster(processing, flows, "PROCESSING");
node(proc1, processing, "aws:lambda", "Processor 1");
node(proc2, processing, "aws:lambda", "Processor 2");
node(proc3, processing, "aws:lambda", "Processor 3");

node(store, events, "aws:s3", "Events Store");
node(analytics, events, "aws:redshift", "Analytics");

Landscape uses a horizontal system flow and stacks replicated members inside their clusters. Portrait rotates the main flow and places replicated members across the available width. Cluster frames resize from their descendants.

Cold topology, then hot behaviour

connect(toWorkers, source, workers);
connect(toQueue, workers, queue);
connect(toProcessors, queue, processing);
connect(toStore, processing, store);

message(event, source, "EVENT");

step("process") {
  seq {
    route(event, toWorkers, 0.8, smooth);
    route(event, toQueue, 0.8, smooth);
    route(event, toProcessors, 0.8, smooth);
  }
}

Connecting a node to a cluster creates the possible lane to every member. Connections are dashed by default: they explain a relationship without falsely claiming that data is flowing. The hot overlay stays solid when activity begins.

The declared connection name addresses the complete fan-out or fan-in group, so draw(toWorkers) and flow(toWorkers, ...) affect every physical lane. route still selects only the lane beginning at the message’s current node.

route is the explicit form. It chooses the physical lane that begins at the message’s current node, illuminates that lane, moves the same message identity, and records its semantic destination. A later route that does not start there fails during manic check instead of teleporting.

Use draw(toWorkers) to reveal every possible lane. Use route for one real message. Use flow(toWorkers, ...) only when the idea is aggregate activity across the worker pool.

Route around a visual obstacle

Connections remain direct unless the creator supplies a signed bend in canvas units:

connect(toDatabase, services, database);
connect(toCache, services, cache, 145*u);

The optional fourth argument changes only the drawn curve and the route that travels over it. Positive and negative values curve on opposite sides. It does not infer obstacles, architecture semantics, or provider behavior; use the smallest bend that keeps the diagram readable on every target canvas.

Use one orthogonal connector

For architecture diagrams that need right-angle routing, keep the connection as one semantic identity:

connect(toDatabase, cache, database, orthogonal);
connect(returnPath, database, api, orthogonal, bottom, right);

message(packet, cache, "GET");
route(packet, toDatabase, 0.9, linear);

orthogonal uses automatic node-boundary ports by default. Add explicit source and destination ports only when the composition needs them: left, right, top, or bottom. The engine builds the internal elbows, but draw, flow, route, and hotpath still address the declared connection id. One message moves across every segment without replacement objects or animation gaps.

This is deterministic Manhattan geometry, not obstacle avoidance. Manic does not inspect service kinds or guess what the connection means.

The connection & arrow grammar

systems-arrow-patterns.manic is the visual reference for every way one connection can be drawn. All six share one rule: the connection describes a possible relationship; direction and meaning come from the moving object (route/flow) and from creator-chosen colour, never from the arrowhead alone.

#PatternHow to author it
1One-way — one source, one destinationconnect(a2b, a, b)
2Round-trip — request and response as separate, honest lanesconnect(fwd, a, b) + connect(ret, b, a) (style/colour them differently)
3Orthogonal — right-angle Manhattan routing, one identityconnect(a2b, a, b, orthogonal)
4Vertical ports — the lane enters/leaves a chosen faceconnect(a2b, a, b, orthogonal, top, bottom)
5Fan-out — N explicitly authored deliveries (not inferred broadcast)connect(toW1, q, w1)connect(toW3, q, w3), or one connect(toWorkers, q, workers) to a cluster
6Diagonal duplex — two directions with distinct stylingtwo connects + per-lane color/hue/dashed

Colour the lanes by relationship so a dense diagram reads at a glance — each connection exposes the cold line id and the hot overlay id.hot:

color(fwd, cyan);   color(fwd.hot, cyan);      // request
hue(ret, 328);      hue(ret.hot, 328);         // response (pink)

Nothing here implies balancing, buffering, or broadcast. A fan-out is possible lanes; the message that moves — and the colour you give each lane — is what tells the runtime story.

Translate a Mingrammer diagram

The structural mapping is deliberately small:

MingrammerManic
Diagram(...)architecture(...)
Cluster(...)cluster(...)
ECS(...), RDS(...), and other provider objectsnode(..., "aws:kind", ...)
a >> bconnect(path, a, b)
a - blink(path, a.card, b.card)
runtime behaviorexplicit message, route, travel, or flow

This keeps the imported design faithful without turning labels such as “lb,” “queue,” or “topic” into hidden behavior. The creator decides which object moves, which path it takes, and whether several motions use seq or par.

The clustered web-services example recreates Route 53, ELB, three ECS services, an RDS primary/read-only pair, and ElastiCache. It then distinguishes all cold relationships from one database round trip, one cache round trip, and a separately authored database-link pulse. Return journeys use separately directed connections and recolour the same persistent identity rather than playing the incoming path backward. The example gives responses a second visual grammar—curved pink dashed arrows, a heavier glow, and a matching response identity—while requests remain clean solid strokes:

title("Clustered Web Services on AWS");
canvas("16:9");
template("neon");

let u = (w+h-abs(w-h))/1440;
watermark(mark, (w*0.15, h*0.025), "Made With Manic");
text(kicker, (cx, h*0.055), "AWS ARCHITECTURE · CLUSTERED WEB SERVICES");
text(headline, (cx, h*0.115), "Requests in. Responses back.");
text(caption, (cx, h*0.935), "Dashed paths describe what is connected—not what is moving now.");
size(kicker, 16*u); color(kicker, dim); bold(kicker);
size(headline, 29*u); bold(headline); wrap(headline, w*0.88);
size(caption, 18*u); color(caption, dim); wrap(caption, w*0.86);

if h > 1.25*w {
  architecture(clusteredWeb, (cx, h*0.51), w*0.84, h*0.70);
  text(requestKey, (w*0.36, h*0.225), "REQUEST");
  arrow(requestSample, (w*0.48, h*0.225), (w*0.64, h*0.225));
  text(responseKey, (w*0.36, h*0.265), "RESPONSE");
  arrow(responseSample, (w*0.48, h*0.265), (w*0.64, h*0.265));
}
else {
  architecture(clusteredWeb, (cx, h*0.51), w*0.94, h*0.69);
  text(requestKey, (w*0.105, h*0.205), "REQUEST");
  arrow(requestSample, (w*0.15, h*0.205), (w*0.205, h*0.205));
  text(responseKey, (w*0.105, h*0.245), "RESPONSE");
  arrow(responseSample, (w*0.15, h*0.245), (w*0.205, h*0.245));
}
size(requestKey, 15*u); color(requestKey, fg); bold(requestKey);
size(responseKey, 15*u); hue(responseKey, 328, 0.90, 0.62); bold(responseKey);
color(requestSample, fg); stroke(requestSample, 3*u);
hue(responseSample, 328, 0.90, 0.62); dashed(responseSample, 10*u, 7*u);
stroke(responseSample, 4*u); glow(responseSample, 0.85);
hidden(requestKey); hidden(requestSample);
hidden(responseKey); hidden(responseSample);

node(dns, clusteredWeb, "aws:route53", "dns");
node(lb, clusteredWeb, "aws:elb", "lb");

cluster(services, clusteredWeb, "SERVICES");
node(web1, services, "aws:ecs", "web1");
node(web2, services, "aws:ecs", "web2");
node(web3, services, "aws:ecs", "web3");

cluster(database, clusteredWeb, "DB CLUSTER");
node(dbPrimary, database, "aws:rds", "userdb");
node(dbReplica, database, "aws:rds", "userdb ro");

node(memcached, clusteredWeb, "aws:elasticache", "memcached");

connect(dnsToLb, dns, lb);
connect(lbToServices, lb, services);
connect(servicesToDb, services, dbPrimary);
connect(servicesToCache, services, memcached, 145*u);
connect(dbToServices, dbPrimary, services, 42*u);
connect(servicesToLb, services, lb, 42*u);
connect(cacheToWeb2, memcached, web2, 145*u);
hue(dbToServices.hot, 328, 0.90, 0.62);
hue(servicesToLb.hot, 328, 0.90, 0.62);
hue(cacheToWeb2.hot, 328, 0.90, 0.62);
dashed(dbToServices.hot, 10*u, 7*u);
dashed(servicesToLb.hot, 10*u, 7*u);
dashed(cacheToWeb2.hot, 10*u, 7*u);
stroke(dbToServices.hot, 4.5*u); glow(dbToServices.hot, 0.95);
stroke(servicesToLb.hot, 4.5*u); glow(servicesToLb.hot, 0.95);
stroke(cacheToWeb2.hot, 4.5*u); glow(cacheToWeb2.hot, 0.95);
link(replication, dbPrimary.card, dbReplica.card);
dashed(replication); color(replication, dim); stroke(replication, 2.5*u);

message(pageRequest, dns, "GET");
message(cacheRead, web2, "READ");

hidden(clusteredWeb.nodes);
hidden(services.parts); hidden(database.parts);
hidden(pageRequest.parts); hidden(cacheRead.parts);
untraced(dnsToLb); untraced(lbToServices);
untraced(servicesToDb); untraced(servicesToCache);
untraced(dbToServices); untraced(servicesToLb); untraced(cacheToWeb2);
untraced(replication);

step("entry") {
  par {
    show(dns, 0.40);
    show(lb, 0.40);
    say(caption, "Route 53 and the load balancer form the public entry into the design.", 0.40);
  }
}
wait(0.20);

step("service-pool") {
  par {
    show(services.parts, 0.45);
    stagger(0.10) {
      show(web1, 0.35);
      show(web2, 0.35);
      show(web3, 0.35);
    }
    say(caption, "Three ECS services share one ownership boundary; no runtime choice is implied yet.", 0.40);
  }
}
wait(0.20);

step("state") {
  par {
    show(database.parts, 0.45);
    show(dbPrimary, 0.40);
    show(dbReplica, 0.40);
    show(memcached, 0.40);
    say(caption, "The database cluster and cache complete the static architecture.", 0.40);
  }
}
wait(0.20);

step("possible-topology") {
  par {
    draw(dnsToLb, 0.40);
    draw(lbToServices, 0.60);
    draw(servicesToDb, 0.60);
    draw(servicesToCache, 0.60);
    draw(replication, 0.45);
    show(requestKey, 0.35); show(requestSample, 0.35);
    show(responseKey, 0.35); show(responseSample, 0.35);
    say(caption, "Every dashed lane is possible. The next moving object will identify the path actually used.", 0.40);
  }
}
wait(0.30);

step("request-to-database") {
  par {
    seq {
      show(pageRequest.parts, 0.20);
      route(pageRequest, dnsToLb, 0.65, smooth);
      route(pageRequest, lbToServices, 0.90, smooth);
      route(pageRequest, servicesToDb, 0.90, smooth);
    }
    say(caption, "This request follows one authored lane: dns → lb → web1 → userdb.", 0.40);
  }
}
wait(0.35);

step("database-response") {
  par {
    seq {
      par {
        say(pageRequest.label, "200 OK", 0.25);
        to(pageRequest.parts, hue, 328, 0.25, smooth);
      }
      route(pageRequest, dbToServices, 0.90, smooth);
      route(pageRequest, servicesToLb, 0.90, smooth);
    }
    say(caption, "The same identity returns userdb → web1 → lb. DNS resolved the name; it is not the HTTP response path.", 0.40);
  }
}
wait(0.35);

step("cache-round-trip") {
  par {
    seq {
      show(cacheRead.parts, 0.20);
      route(cacheRead, servicesToCache, 1.05, smooth);
      par {
        say(cacheRead.label, "HIT", 0.25);
        to(cacheRead.parts, hue, 328, 0.25, smooth);
      }
      route(cacheRead, cacheToWeb2, 1.05, smooth);
    }
    say(caption, "A separate cache read travels web2 → memcached, becomes HIT, and returns to that same service.", 0.40);
  }
}
wait(0.35);

step("replica-relationship") {
  par {
    flow(replication, 3.20, both, continuous);
    say(caption, "The neutral database link can carry a two-way visual pulse only because the creator asked for it.", 0.40);
  }
}
wait(0.35);

step("takeaway") {
  par {
    pulse(services.parts, 0.70);
    pulse(database.parts, 0.70);
    say(caption, "Architecture stays readable; objects, paths, timing, and composition tell the runtime story.", 0.45);
  }
}
wait(1.40);

Infer one complete hot path

For the common architecture-explainer shot, Manic can follow the graph itself:

message(event, source, "EVENT");

step("runtime") {
  hotpath(event, 6.0, 27);
}

hotpath(message, [duration], [seed]) begins at the message’s current node, finds valid outgoing physical lanes, chooses one at every fan-out, and keeps the same dot moving until it reaches a sink. Only the selected lanes illuminate; all other relationships stay dashed and quiet.

The optional seed controls the branch choices. The result feels random to a viewer but is deterministic across previews, backend renders, and WASM. Change the seed to show another valid execution without rewriting the story. Use route when the exact service sequence is part of the lesson; use hotpath when the lesson is “one possible runtime through this topology.”

Directional and continuous flow

flow(path, 0.8);                         // forward, one clean pulse
flow(path, 4.0, forward, continuous);    // finite repeating stream
flow(returnPath, 1.0, reverse, once);    // reverse pulse on generic geometry
flow(syncPath, 4.0, both, continuous);   // independent duplex streams

The full signature is:

flow(path, [duration], [forward|reverse|both], [once|continuous])

Continuous flow chooses an integer number of length-aware cycles. It begins empty and ends on a cycle boundary, so seeking remains deterministic and the stream drains instead of freezing halfway along the connection. In a Systems story, prefer a separately directed return connection when the topology truly contains a response path.

Complete event-processing story

title("Event Processing on AWS — From Topology to Hot Path");
canvas("16:9");
template("mono");

let u = (w+h-abs(w-h))/1440;
watermark(mark, (w*0.15, h*0.055), "Made With Manic");
text(kicker, (cx, h*0.055), "AWS ARCHITECTURE · EVENT PROCESSING");
text(headline, (cx, h*0.115), "One topology. One event. One visible hot path.");
text(caption, (cx, h*0.935), "Dashed relationships show every route the architecture allows.");
size(kicker, 16*u); color(kicker, dim); bold(kicker);
size(headline, 29*u); bold(headline); wrap(headline, w*0.88);
size(caption, 18*u); color(caption, dim); wrap(caption, w*0.86);

if h > 1.25*w {
  architecture(eventProcessing, (cx, h*0.51), w*0.82, h*0.70);
}
else {
  architecture(eventProcessing, (cx, h*0.51), w*0.92, h*0.69);
}

node(source, eventProcessing, "aws:eks", "K8s Source");
cluster(flows, eventProcessing, "EVENT FLOWS");
cluster(workers, flows, "EVENT WORKERS");
node(worker1, workers, "aws:ecs", "Worker 1");
node(worker2, workers, "aws:ecs", "Worker 2");
node(worker3, workers, "aws:ecs", "Worker 3");
node(queue, flows, "aws:sqs", "Event Queue");
cluster(processing, flows, "PROCESSING");
node(proc1, processing, "aws:lambda", "Processor 1");
node(proc2, processing, "aws:lambda", "Processor 2");
node(proc3, processing, "aws:lambda", "Processor 3");
node(store, eventProcessing, "aws:s3", "Events Store");
node(warehouse, eventProcessing, "aws:redshift", "Analytics");

connect(toWorkers, source, workers);
connect(toQueue, workers, queue);
connect(toHandlers, queue, processing);
connect(toStore, processing, store);
connect(toAnalytics, processing, warehouse);

message(event, source, "EVENT");

hidden(eventProcessing.nodes);
hidden(flows.parts); hidden(workers.parts); hidden(processing.parts);
hidden(event.parts);
untraced(toWorkers); untraced(toQueue); untraced(toHandlers);
untraced(toStore); untraced(toAnalytics);

step("source") {
  par {
    show(source, 0.45);
    say(caption, "EKS is the source; the rest of the system is still only structure.", 0.40);
  }
}
wait(0.25);

step("ownership") {
  par {
    show(flows.parts, 0.50);
    show(workers.parts, 0.45);
    stagger(0.10) { show(worker1, 0.35); show(worker2, 0.35); show(worker3, 0.35); }
    show(queue, 0.40);
    show(processing.parts, 0.45);
    stagger(0.10) { show(proc1, 0.35); show(proc2, 0.35); show(proc3, 0.35); }
    show(store, 0.40); show(warehouse, 0.40);
    say(caption, "Nested boundaries reveal ownership without hiding the individual services.", 0.40);
  }
}
wait(0.20);

step("possible-topology") {
  par {
    draw(toWorkers, 0.55);
    draw(toQueue, 0.55);
    draw(toHandlers, 0.55);
    draw(toStore, 0.55); draw(toAnalytics, 0.55);
    say(caption, "Every dashed edge is a possible relationship—not a claim that data is flowing.", 0.40);
  }
}
wait(0.35);

step("runtime-hot-path") {
  par {
    show(event.parts, 0.25);
    hotpath(event, 6.20, 27);
    say(caption, "The event now chooses valid branches and travels end-to-end as one continuous hot path.", 0.40);
  }
}
wait(0.35);

step("takeaway") {
  par {
    pulse(event.parts, 0.70);
    say(caption, "Cold topology explains relationships. The moving dot proves what happened now.", 0.45);
  }
}
wait(1.40);

Run and audit it directly:

manic examples/aws-event-processing-clusters-poc.manic
manic check examples/aws-event-processing-clusters-poc.manic --canvas all

What the kit will not do for you

By design, the kit never infers behaviour from an icon or a label. It will not decide that a node is a load balancer, a queue, or a database, and it never simulates balancing, buffering, broadcast, retries, or failure. You author what happens with explicit route, flow, seq, par, colour, and ordinary verbs — that is what keeps the vocabulary small and the diagram honest.

Two practical tips:

  • Draw the return path, don’t reverse the arrow. A response is its own connection with its own colour, not the request lane played backward.
  • For very dense diagrams, shape the layout yourself. Group nodes into clusters and split wide rows into columns so nothing overflows the frame.