You just have to learn 4 things to know Object-Spatial Programming#
Most programming paradigms ask you to learn dozens of concepts, patterns, and best practices. Object-Spatial Programming (OSP) is different. At its core, there are really just four fundamental concepts you need to understand. Master these four things, and you'll have unlocked an entirely new way of thinking about computation—one that's particularly well-suited for modern applications dealing with complex relationships, graph structures, and distributed data.
This article will teach you the complete model of Object-Spatial Programming through these four conceptual buckets. Each builds naturally on the last, and by the end, you'll understand why OSP represents a genuine paradigm shift in how we structure programs.
What is Object-Spatial Programming?#
Before we dive into the four concepts, let's establish what makes Object-Spatial Programming fundamentally different from traditional Object-Oriented Programming (OOP).
Traditional OOP:
Object-Spatial Programming:
Data is arranged in space → You send computation to travel through the data
(Walkers visit nodes in a graph)
The Restaurant Analogy
Think of it this way:
- OOP: You're at home, you call restaurants and have food delivered to you
- OSP: You send a robot out to visit different restaurants and collect food
Both get you fed, but they work fundamentally differently! In OSP, computation is mobile and data is spatial.
Concept 1: The Spatial Object Model (Nodes & Edges)#
The first thing you need to understand is how objects are structured in Object-Spatial Programming. Instead of thinking about isolated objects, you think about objects arranged in space with explicit relationships.
🔑 Core Insight: Everything in OSP is built on classes.
node,edge, andwalkerare not new primitive types—they're classes that inherit all OOP capabilities (methods, properties, inheritance, polymorphism) and add spatial/traversal semantics. This means OSP extends OOP rather than replacing it.
The Two Pillars of the Spatial Object Model
| Type | What It Is | Purpose | Traditional Analogy |
|---|---|---|---|
node |
Class with spatial semantics | Data locations that can be connected in graphs | Houses on a map |
edge |
Class representing relationships | First-class connections between nodes | Roads between houses |
Key point: Nodes and edges are full-featured classes with methods, inheritance, and properties—PLUS spatial connection capabilities.
Creating Nodes
Important: Nodes are classes! They have all the semantics of regular classes (methods, inheritance, polymorphism) PLUS the ability to be connected in spatial graphs.
node Person {
has name: str;
has age: int;
def greet -> str {
return f"Hello, I'm {self.name}!";
}
def celebrate_birthday {
self.age += 1;
print(f"{self.name} is now {self.age}!");
}
}
with entry {
alice = Person(name="Alice", age=25);
bob = Person(name="Bob", age=30);
print(alice.greet());
alice.celebrate_birthday();
print(bob.greet());
}
At this point, nodes work exactly like regular classes. The spatial magic happens when we connect them...
Connecting Nodes: The Spatial Operators
This is where OSP diverges from traditional OOP. You can directly connect nodes using spatial operators:
node Person {
has name: str;
}
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
charlie = Person(name="Charlie");
# Build the graph structure
alice ++> bob; # Alice → Bob
alice ++> charlie; # Alice → Charlie
bob ++> charlie; # Bob → Charlie
root ++> alice;
# Query connections
connections = [alice -->];
print(f"Alice is connected to {len(connections)} person(s)");
}
Visual representation:
graph TD
Alice --> Bob
Alice --> Charlie
Bob --> Charlie
Connection Operators Reference
| Operator | Direction | Example | Result |
|---|---|---|---|
++> |
Forward | alice ++> bob; |
alice → bob |
<++ |
Backward | alice <++ bob; |
bob → alice |
<++> |
Both ways | alice <++> bob; |
alice ↔ bob |
Typed Edges: First-Class Relationships
Here's what makes OSP truly revolutionary: relationships are first-class objects with their own types and properties!
Important: Edges are also classes! Like nodes, edges have all the semantics of regular classes—they can have methods, properties, and behavior—PLUS they represent connections in the graph.
node Person {
has name: str;
}
edge Friend {
has since: int;
has strength: int = 5;
def years_of_friendship(current_year: int) -> int {
return current_year - self.since;
}
def is_strong -> bool {
return self.strength >= 7;
}
}
edge Coworker {
has department: str;
}
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
charlie = Person(name="Charlie");
root ++> alice;
alice +>:Friend(since=2015, strength=9):+> bob;
alice +>:Coworker(department="Engineering"):+> charlie;
friends = [alice ->:Friend:->];
coworkers = [alice ->:Coworker:->];
print(f"Alice has {len(friends)} friend(s) and {len(coworkers)} coworker(s)");
}
This is a paradigm shift! In traditional OOP, relationships are just data in lists or dictionaries. In OSP, relationships are classes in their own right that exist in the spatial model and can have methods, properties, and behavior.
graph LR
Alice -->|Friend since 2015<br/>strength: 9| Bob
Alice -->|Coworker<br/>dept: Engineering| Charlie
Querying the Graph: Edge References
Once you've built a graph structure, you can query it using edge references with square brackets:
node Person {
has name: str;
}
edge Friend {
has since: int;
}
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
charlie = Person(name="Charlie");
root ++> alice;
alice +>:Friend(since=2015):+> bob;
alice +>:Friend(since=2020):+> charlie;
all_connections = [alice -->];
print(f"Total connections: {len(all_connections)}");
friends = [alice ->:Friend:->];
print(f"Friends: {len(friends)}");
old_friends = [alice ->:Friend:since < 2018:->];
print(f"Friends since before 2018: {len(old_friends)}");
}
Edge Reference Patterns
| Pattern | Direction | Type Filter | Example |
|---|---|---|---|
[node -->] |
Outgoing | Any | All outgoing connections |
[node <--] |
Incoming | Any | All incoming connections |
[node <-->] |
Both | Any | All connections |
[node ->:Type:->] |
Outgoing | Specific | Specific edge type |
[node ->:Type:property > 5:->] |
Outgoing | Filtered | With property filter |
Why This Matters: Traditional vs Spatial
Traditional OOP approach:
obj Person {
has name: str;
has connections: list = [];
}
obj Connection {
has conn_type: str;
has target: object;
}
def find_friends(person: Person) -> list {
friends = [];
for connection in person.connections {
if connection.conn_type == "friend" {
friends.append(connection.target);
}
}
return friends;
}
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
conn = Connection(conn_type="friend", target=bob);
alice.connections.append(conn);
friends = find_friends(alice);
print(f"Alice's friends: {len(friends)}");
}
OSP approach:
Key advantages: 1. Natural representation: Relationships are explicit in the structure 2. Type safety: Edges have their own types and properties 3. Declarative queries: Express what you want, not how to get it 4. Visual clarity: The code structure mirrors the data structure
Nodes and Edges Support Full OOP
Since nodes and edges are classes, they support all standard OOP features:
node Entity {
has id: str;
has created_at: str;
}
node Person(Entity) {
has name: str;
has email: str;
def send_notification(message: str) {
print(f"Sending to {self.email}: {message}");
}
}
node Organization(Entity) {
has company_name: str;
has industry: str;
def get_display_name -> str {
return f"{self.company_name} ({self.industry})";
}
}
edge Employment {
has role: str;
has start_date: str;
}
with entry {
alice = Person(id="1", created_at="2020-01-01", name="Alice", email="alice@example.com");
acme = Organization(id="2", created_at="2015-01-01", company_name="Acme Corp", industry="Tech");
root ++> alice;
alice +>:Employment(start_date="2020-06-01", role="Engineer"):+> acme;
alice.send_notification("Welcome!");
print(acme.get_display_name());
jobs = [alice ->:Employment:->];
print(f"Alice has {len(jobs)} job(s)");
}
The key insight: node, edge, and walker are not restrictions—they're extensions of regular classes that add spatial semantics while preserving all OOP capabilities.
Now that you understand how objects are structured spatially, let's see how computation moves through that space.
Concept 2: The Mobile Computation Model (Walkers)#
The second concept is about how computation moves through spatial structures. Instead of bringing data to static functions, you send mobile units of computation—walkers—through your object graph.
What is a Walker?
A walker is a mobile unit of computation that travels through the graph, visiting nodes and performing actions. Think of it as an autonomous agent that navigates your object graph.
Important: Walkers are also classes! They have all the semantics of regular classes (state, methods, inheritance) PLUS the ability to move through the graph and trigger abilities based on what they visit.
node Person {
has name: str;
}
walker Greeter {
has greeting_count: int = 0;
can start with `root entry {
print("Starting journey!");
visit [-->];
}
can greet with Person entry {
print(f"Hello, {here.name}!");
self.greeting_count += 1;
visit [-->];
}
}
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
charlie = Person(name="Charlie");
root ++> alice ++> bob ++> charlie;
greeter = Greeter();
root spawn greeter;
print(f"Total greetings: {greeter.greeting_count}");
}
The walker travels through the graph, greeting each person it encounters. Output shows the journey:
Walker Structure
Walkers are classes with state (using has) and abilities (using can):
walker MyWalker {
has counter: int = 0;
has visited_names: list = [];
can ability_name with NodeType entry {
# Logic executes when visiting NodeType
}
}
Navigation with Visit
The visit statement controls where walkers go next. It uses the same edge reference syntax you learned for queries:
walker Explorer {
can explore with Person entry {
print(f"Visiting {here.name}");
visit [-->]; # Continue to outgoing connections
}
}
| Visit Pattern | Navigates To |
|---|---|
visit [-->]; |
All nodes via outgoing edges |
visit [<--]; |
All nodes via incoming edges |
visit [<-->]; |
All connected nodes (any direction) |
visit [->:Type:->]; |
Nodes via specific edge type |
visit [->:Type:property > 5:->]; |
Nodes via filtered edges |
visit node_list; |
Specific nodes in a list |
Special References in Walkers
Within walker abilities, three references are always available:
| Reference | What It Is |
|---|---|
self |
The walker instance (access walker state) |
here |
The current node being visited (access node properties) |
root |
The global root node (always available anchor) |
walker DataCollector {
has collected_data: list = [];
can collect with Person entry {
self.collected_data.append(here.name);
print(f"Collected: {here.name}");
visit [-->];
}
}
Example: Finding a Specific Node
node Person {
has name: str;
}
walker FindPerson {
has target: str;
has found: bool = False;
can start with `root entry {
visit [-->];
}
can search with Person entry {
if here.name == self.target {
print(f"Found {here.name}!");
self.found = True;
disengage;
}
visit [-->];
}
}
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
charlie = Person(name="Charlie");
root ++> alice ++> bob ++> charlie;
finder = FindPerson(target="Bob");
root spawn finder;
print(f"Search result: {finder.found}");
}
graph TD
Root[Root] -->|1. spawn| Alice[Person Alice]
Alice -->|2. visit| Bob[Person Bob]
Bob[Person Bob - FOUND!]
Bob -.->|3. disengage| Charlie[Person Charlie<br/>not visited]
style Bob fill:#90EE90
style Charlie fill:#FFB6C6
This fundamental shift—from bringing data to computation, to sending computation to data—enables natural graph traversal, automatic type-based dispatching, and the potential for distributed execution across machine boundaries.
Concept 3: The Event-Driven Interaction Model (Abilities)#
The third concept defines how walkers and nodes interact through abilities—methods that automatically execute when walkers visit nodes.
What are Abilities?
Abilities are special methods that automatically execute based on type matching between walkers and nodes. They define what happens when a walker visits a specific node type.
walker MyWalker {
can meet_person with Person entry {
print(f"Meeting {here.name}");
}
can visit_city with City entry {
print(f"Visiting {here.name}");
}
}
When Abilities Execute
| Pattern | When It Triggers |
|---|---|
with NodeType entry |
Walker enters a node of this type |
with NodeType exit |
Walker leaves a node of this type |
with WalkerType entry |
A walker of this type visits (node perspective) |
with \root entry` |
Walker spawns at root node |
Walker Abilities: The Walker's Perspective
Walker abilities define what happens when the walker visits different node types:
node Person {
has name: str;
has age: int;
}
node City {
has name: str;
has population: int;
}
walker Tourist {
has places_visited: int = 0;
can start with `root entry {
print("Starting tour!");
visit [-->];
}
can meet_person with Person entry {
print(f"Met {here.name}, age {here.age}");
self.places_visited += 1;
visit [-->];
}
can visit_city with City entry {
print(f"Visiting {here.name}, population {here.population}");
self.places_visited += 1;
visit [-->];
}
}
with entry {
alice = Person(name="Alice", age=30);
paris = City(name="Paris", population=2161000);
root ++> alice ++> paris;
root spawn Tourist();
}
Node Abilities: The Node's Perspective
Nodes can also define abilities that trigger when specific walker types visit them:
node Person {
has name: str;
has greeting_count: int = 0;
can be_greeted with Greeter entry {
self.greeting_count += 1;
print(f"{self.name} has been greeted {self.greeting_count} time(s)");
}
}
walker Greeter {
can greet with Person entry {
print(f"Hello, {here.name}!");
visit [-->];
}
}
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
root ++> alice ++> bob;
root spawn Greeter();
}
Bidirectional Polymorphism: The Revolutionary Part
Here's what makes OSP unique: both nodes and walkers can define abilities for the same interaction. When a walker visits a node, both abilities execute!
node Person {
has name: str;
can greet_visitor with Visitor entry {
print(f"{self.name} says: Welcome!");
}
}
walker Visitor {
can meet_person with Person entry {
print(f"Visitor says: Hello, {here.name}!");
visit [-->];
}
}
with entry {
alice = Person(name="Alice");
root ++> alice;
root spawn Visitor();
}
Both abilities execute automatically, creating a bidirectional interaction contract.
When a walker visits a node, abilities execute in order: node ability first, then walker ability. This bidirectional execution enables powerful coordination between spatial data and mobile computation.
In this example, both the node and walker define abilities, and both execute automatically when the walker visits:
Abilities enable automatic type-based dispatch, separation of concerns between walkers and nodes, and bidirectional interaction contracts—capabilities that have no direct equivalent in traditional OOP.
Concept 4: The Traversal & Control Flow (Navigation & Results)#
The fourth concept covers how to control walker navigation, collect results, and orchestrate computation across your spatial graph.
Visit Strategies
Visit statements support sophisticated navigation patterns:
| Pattern | Purpose |
|---|---|
visit [-->]; |
All outgoing connections |
visit [->:Friend:->]; |
Only Friend edge types |
visit [->:Friend:since < 2020:->]; |
Filtered by edge property |
visit [here ->:Friend:-> ->:Friend:->]; |
Multi-hop (friends of friends) |
You can also query first, then visit specific nodes from a filtered list.
Collecting Results: Report vs Return
Unlike return (which stops execution), report streams values back while the walker continues executing.
node Person {
has name: str;
has age: int;
}
walker AgeCollector {
has ages: list = [];
can start with `root entry {
visit [-->];
}
can collect with Person entry {
self.ages.append(here.age);
visit [-->];
}
}
with entry {
alice = Person(name="Alice", age=25);
bob = Person(name="Bob", age=30);
charlie = Person(name="Charlie", age=28);
root ++> alice ++> bob ++> charlie;
collector = AgeCollector();
root spawn collector;
print(f"Collected ages: {collector.ages}"); # [25, 30, 28]
print(f"Average age: {sum(collector.ages) / len(collector.ages):.2f}"); # 27.67
}
Walker state accumulates data as it traverses, allowing you to access collected information after execution completes.
| Feature | return |
report |
|---|---|---|
| Stops execution | Yes | No |
| Sends value back | One value | Multiple values (stream) |
| Continues walking | No | Yes |
| Result type | Single value | List of values |
Early Exit: Disengage
Use disengage to stop walker execution immediately, no matter where it is in the graph. This is useful for search operations where you want to stop once a condition is met.
node Person {
has name: str;
}
walker FindPerson {
has target: str;
has found: bool = False;
can start with `root entry {
visit [-->];
}
can search with Person entry {
if here.name == self.target {
print(f"Found {here.name}!");
self.found = True;
disengage;
}
visit [-->];
}
}
with entry {
alice = Person(name="Alice");
bob = Person(name="Bob");
charlie = Person(name="Charlie");
root ++> alice ++> bob ++> charlie;
finder = FindPerson(target="Bob");
root spawn finder;
print(f"Search result: {finder.found}");
}
You can combine report and disengage to find and return the first matching node, then stop immediately.
Visit patterns naturally create depth-first traversal (visiting all connections recursively). For breadth-first or custom ordering, maintain a queue in walker state. Conditional traversal is achieved by filtering visit statements with edge types or properties.
Complete Example: Friend Recommendations
Here's how all traversal concepts work together:
node User {
has username: str;
}
edge Friendship {
has since: int;
}
walker FriendRecommender {
has recommendations: list = [];
can start with `root entry {
visit [-->];
}
can find_recommendations with User entry {
# Get friends of friends (2 hops)
friends_of_friends = [here ->:Friendship:-> ->:Friendship:->];
# Get direct friends to exclude
direct_friends = [here ->:Friendship:->];
for person in friends_of_friends {
if person not in direct_friends and person != here {
if person.username not in self.recommendations {
self.recommendations.append(person.username);
}
}
}
disengage;
}
}
with entry {
alice = User(username="alice");
bob = User(username="bob");
charlie = User(username="charlie");
root ++> alice;
alice +>:Friendship(since=2020):+> bob;
bob +>:Friendship(since=2021):+> charlie;
recommender = FriendRecommender();
alice spawn recommender;
print(f"Recommendations: {recommender.recommendations}");
}
This example demonstrates multi-hop queries, filtering, and early exit—all core traversal patterns in OSP.
| Mechanism | Purpose | Stops Walker? | Returns Data? |
|---|---|---|---|
visit |
Navigate to nodes | No | No |
report |
Stream values back | No | Yes (multiple) |
disengage |
Stop immediately | Yes | No |
return |
Exit current ability | No | No |
Putting It All Together: The Four Concepts#
Here's how all four concepts work together in a complete example:
node Task {
has title: str;
has status: str = "pending";
has priority: int = 1;
}
edge DependsOn {}
walker TaskAnalyzer {
has ready_tasks: list = [];
has blocked_tasks: list = [];
can start with `root entry {
visit [-->];
}
can analyze with Task entry {
if here.status != "pending" {
return;
}
dependencies = [here ->:DependsOn:->];
all_done = True;
for dep in dependencies {
if dep.status != "complete" {
all_done = False;
break;
}
}
if all_done {
self.ready_tasks.append(here.title);
} else {
self.blocked_tasks.append(here.title);
}
visit [-->];
}
}
walker MarkComplete {
has task_title: str;
can start with `root entry {
visit [-->];
}
can mark with Task entry {
if here.title == self.task_title {
here.status = "complete";
print(f"Completed: {here.title}");
disengage;
}
visit [-->];
}
}
with entry {
task1 = Task(title="Design database", priority=3);
task2 = Task(title="Implement API", priority=3);
task3 = Task(title="Create frontend", priority=2);
task4 = Task(title="Write tests", priority=2);
task5 = Task(title="Deploy", priority=3);
task2 +>:DependsOn:+> task1;
task3 +>:DependsOn:+> task2;
task4 +>:DependsOn:+> task2;
task5 +>:DependsOn:+> task3;
task5 +>:DependsOn:+> task4;
root ++> task1;
root ++> task2;
root ++> task3;
root ++> task4;
root ++> task5;
analyzer = TaskAnalyzer();
root spawn analyzer;
print(f"Ready: {analyzer.ready_tasks}");
print(f"Blocked: {analyzer.blocked_tasks}");
root spawn MarkComplete(task_title="Design database");
analyzer2 = TaskAnalyzer();
root spawn analyzer2;
print(f"\nAfter completion:");
print(f"Ready: {analyzer2.ready_tasks}");
print(f"Blocked: {analyzer2.blocked_tasks}");
}
Dependency Graph:
graph TB
T1[Design database<br/>Priority: 3]
T2[Implement API<br/>Priority: 3]
T3[Create frontend<br/>Priority: 2]
T4[Write tests<br/>Priority: 2]
T5[Deploy<br/>Priority: 3]
T2 -->|DependsOn| T1
T3 -->|DependsOn| T2
T4 -->|DependsOn| T2
T5 -->|DependsOn| T3
T5 -->|DependsOn| T4
style T1 fill:#90EE90
style T2 fill:#FFB6C6
style T3 fill:#FFB6C6
style T4 fill:#FFB6C6
style T5 fill:#FFB6C6
The Four Concepts: Summary Table#
| Concept | What It Is | Key Elements | What It Enables |
|---|---|---|---|
| 1. Spatial Object Model | How objects are structured using classes with spatial semantics | node (class), edge (class), ++>, [-->] |
First-class relationships, declarative queries, full OOP |
| 2. Mobile Computation | How computation moves using walker classes | walker (class), spawn, visit |
Autonomous traversal, data-centric computation |
| 3. Event-Driven Interaction | How things react via abilities (special methods) | Abilities, with entry, here, self |
Automatic dispatch, bidirectional polymorphism |
| 4. Traversal & Control | How you navigate & collect data | visit patterns, report, disengage |
Sophisticated navigation, streaming results |
Remember: Nodes, edges, and walkers are all full-featured classes. They support inheritance, methods, properties, and all OOP features—OSP adds spatial and traversal capabilities on top.
When to Use Object-Spatial Programming#
OSP shines in specific scenarios:
Perfect For:
✓ Social networks - Users, friendships, followers ✓ Knowledge graphs - Concepts and their relationships ✓ Recommendation systems - Users, products, ratings ✓ Workflow systems - Tasks with dependencies ✓ Transportation networks - Cities, routes, connections ✓ Organization charts - People and reporting structures ✓ Dependency management - Packages, versions, requirements
Not Ideal For:
✗ Simple CRUD applications ✗ Data with no significant relationships ✗ Performance-critical tight loops ✗ Simple list/table processing
The fundamental difference:
| Traditional OOP | Object-Spatial Programming |
|---|---|
| Objects are isolated | Objects (nodes) are spatially connected |
| Relationships are data in lists/dicts | Relationships are first-class objects (edges) |
| Computation is static | Computation is mobile (walkers) |
| Method calls are explicit | Abilities dispatch automatically |
| One-way interaction | Bidirectional interaction |
Conclusion: Just Four Things#
You've now learned the complete model of Object-Spatial Programming through four fundamental concepts:
- The Spatial Object Model - Structure your objects in space with nodes and edges (classes with spatial semantics)
- The Mobile Computation Model - Send walkers (classes) to travel through your object graph
- The Event-Driven Interaction Model - Define abilities (special methods) for automatic, bidirectional interaction
- The Traversal & Control Flow - Navigate with visit, collect with report, control with disengage
That's it. Four concepts that unlock an entirely new way of thinking about computation.
The fundamental insight about OSP:
Nodes, edges, and walkers are not new constructs—they're classes with all the power of OOP (methods, inheritance, polymorphism) plus spatial and traversal capabilities.
This means you keep all the programming knowledge you already have and add spatial semantics on top. When your problem domain involves complex relationships and interconnected data, Object-Spatial Programming provides a natural, powerful way to model and solve it.
The key insight:
"Don't bring data to computation. Send computation to where the data lives."
This simple shift in perspective—from static objects to spatial objects, from method calls to mobile computation—opens up new possibilities for how we structure programs. Welcome to Object-Spatial Programming.
Further Learning#
Want to dive deeper? Explore these resources:
- Jac Language Documentation - Complete language reference
- Example Projects - Real-world OSP applications
- Graph Algorithms in OSP - Classic algorithms reimagined
- Distributed OSP - Scaling walkers across machines
Ready to build something? Start with a simple social network, then work your way up to more complex graph-based applications. The four concepts you've learned here are all you need to get started.
Happy spatial programming! 🚀