Skip to content

Repository files navigation

pseudo-mud

CI

An experiment in building the physical core of a MUD on top of an Entity-Component-System, using artemis-odb.

There are no rooms, no combat, no items and no network layer here. Instead, this prototype attacks the part of MUD-style simulation that is usually hand-waved or hardcoded: entities physically interacting with each other's movement. Riding a horse. Hitching that horse to a cart. Sitting on the cart while someone else drives it. Leading a mount by the reins. Following someone who is himself riding away. All of it composes from a handful of small components and systems, with no special cases for any particular combination.

The flagship scenario, which works today and is covered by the test suite:

        rider (0)                       passenger (3)    companion (4)
           |                                 |                :
         rides                           carried by        follows (3)
           v                                 v                :
        horse (1)  ---- pulls ---->       cart (2)

One move command from the rider and the whole convoy travels together, cell by cell, arriving in lock-step — while the companion, on foot and slower, periodically falls behind and catches up whenever the group pauses.

Quick start

Requires Java 21+ — mise install sets up the JDK pinned in mise.toml, or point JAVA_HOME at any JDK 21+ and let Gradle provision its own toolchain. Everything else is fetched by the Gradle wrapper.

./gradlew run     # interactive sandbox
./gradlew test    # composite-movement scenario + action-failure tests
./gradlew build   # jar + distribution under build/

The sandbox

./gradlew run drops you into a console REPL. The world is deliberately turn-based here: each line of input advances the simulation by exactly one tick (1/25th of a second of game time), which makes every experiment deterministic and inspectable. An empty line just waits a tick.

On startup the demo cast is created — all on the same cell — and wired into the convoy above:

id who speed¹ capabilities
0 rider 0.10 Carriable, Followable
1 horse 0.05 Ridable, Pullable, Followable
2 cart –² Drivable, Pullable
3 passenger –² Carriable, Followable
4 companion 0.10 Carriable, Followable

¹ seconds of game time per cell — lower is faster. ² the cart moves at its puller's pace, and the passenger at the cart's, so their own speeds don't matter.

Commands

q                            quit
c                            create an entity (Pos, Speed, Enaction)
d <id>                       delete an entity
l <id>                       list an entity's components
?<component> <id>            show a component        (e.g. '?pos 3')
<component> <id>             add a component         (e.g. 'pullable 3')
move <id> <side>             move (n ne e se s sw w nw up down)
stop <id>                    interrupt the current action
ride <riderId> <rideId>      mount, or dismount if already riding it
drive <driverId> <cartId>    (un)take the reins of a cart you are on
pull <id> <targetId>         start/stop pulling
pull <id> <rideId> <cartId>  hitch/unhitch a ride to a cart
carry <id> <targetId>        get on/off a carrier
carry <id> <tId> <ontoId>    load/unload something onto a carrier
follow <id> <targetId>       follow a target (self/none to stop)
stance <id> <stance>         change body stance (requires Body)

An annotated session

1> move 0 w                     # the RIDER asks to move west...
msg: MoveAction{direction=W, ..., actorId=1, ...}            # ...so the HORSE moves
msg: MoveAction{..., actorId=0, ..., status=CANCELLED, ...}  # rider's own move is absorbed
msg: MoveAction{direction=W, type=PULLED, masterId=1, actorId=2, ...}  # cart dragged along
2>                              # empty line: wait one tick
msg: MoveAction{direction=W, type=FOLLOWING, ..., actorId=4, ...}      # companion sets off
msg: MOVED{actorId=0, from=Pos{x=10, y=10}, to=Pos{x=9, y=10}, type=CARRIED}
msg: MOVED{actorId=1, from=Pos{x=10, y=10}, to=Pos{x=9, y=10}, type=NORMAL}
msg: MOVED{actorId=2, from=Pos{x=10, y=10}, to=Pos{x=9, y=10}, type=PULLED}
msg: MOVED{actorId=3, from=Pos{x=10, y=10}, to=Pos{x=9, y=10}, type=CARRIED}
3> ?pos 4
msg: MOVED{actorId=4, ..., type=FOLLOWING}    # the companion arrives a tick later:
[?pos] 4: Pos{x=9, y=10}                      # on foot, it is slower than the horse

Note how the four convoy members complete their step on the same tick and with the correct movement type — the rider and passenger were CARRIED, the cart was PULLED, and only the horse actually moved of its own accord.

Core ideas

1. Actions are events

Every command becomes an Action — a plain object carrying the actor id and its parameters. Actions are dispatched on an event bus (artemis-odb-contrib's EventSystem), and each gameplay system @Subscribes only to the action types it knows how to handle:

@Subscribe
public void handle(final RideAction action) { ... }

The dispatched instance is also the result carrier: handlers move it through a small lifecycle (NEW → RUNNING → SUCCESS / FAILED / CANCELLED).

Crucially, a rejected action always says why. Handlers never silently return: they call ActionSystem.fail(action, reason) with an ActionStatusBEING_CARRIED, PULLER_BEING_RIDDEN, OUT_OF_BOUNDS, … — which both stamps the action and dispatches a FAILED event carrying the actor and the reason. Output systems can turn that into a player-facing message ("you can't walk off a moving cart") without knowing anything about carts:

> move 3 n                                       # 3 is a passenger on the cart
[event] FAILED{MoveAction, actorId=3, reason=BEING_CARRIED}

Internal helpers return ActionStatus rather than boolean for the same reason — the why survives all the way from the check that rejected it to the player.

Scheduling goes through the ActionSystem: each entity that can act has an Enaction component holding a queue of pending actions, drained one per tick. Actions have two flags that make interruption composable:

  • an action that interrupts cancels whatever the actor is currently doing (a StopAction is dispatched so the owning system can clean up);
  • an interruptible action is remembered as the actor's current action, so a later interrupting action — or an explicit stop — can cancel it.

2. Capability triads: -able, -ing, -ed

Each physical interaction is described by three tiny components:

capability (flag) active side passive side system
Ridable Riding Ridden RideSystem
Drivable Driving Driven DriveSystem
Pullable Pulling Pulled PullSystem
Carriable Carrying Carried CarrySystem
Followable Following Followed FollowSystem

The flag component marks what an entity can have done to it (data, set at creation); the -ing/-ed pair is a live link between two entities, created and removed by the corresponding system. Because these are just components, capabilities compose freely: the horse is Ridable + Pullable + Followable, and nothing anywhere special-cases "horse".

Relationships stay honest thanks to artemis-odb's EntityLinkManager: the @EntityId fields in the -ing/-ed components are tracked, and each system registers a LinkAdapter to react when the other end of a link dies (your mount despawns → your Riding is removed and you stop moving).

3. Movement takes time

There is no teleporting from cell to cell. A MoveAction attaches a Moving component whose delay — the entity's Speed, in seconds-per-cell — is counted down by a DelayedIteratingSystem. Only when it expires does the position change and a MOVED event go out. This is what makes speed differences emergent: the companion doesn't "lag by one cell" because anyone programmed that; it simply takes 0.10s per cell while the horse takes 0.05s.

4. Movement is re-routed, then induced

MoveSystem is the front door for all movement, and the first thing it does is ask who should really move:

  • a rider's move is cancelled and re-issued as the mount's move;
  • a driver's move is validated along the whole train (cart hitched? ride free of rider and leash?) and re-issued as the pulling ride's move;
  • a carried entity cannot move on its own at all;
  • a pulled entity that moves of its own volition breaks free of its puller first.

Once something genuinely moves, the interaction systems watch for it (artemis aspect subscriptions on Moving + Pulling, Moving + Carrying, etc.) and induce movement on the linked entities: the cart is forced to move with the puller's exact direction and remaining delay, passengers with the cart's, and so on down the chain. Induced moves carry a masterId and a MoveTypes tag (PULLED, CARRIED, FOLLOWING), so a move can always be traced back to what caused it — and copying the master's remaining delay is what makes the whole convoy arrive on the same tick.

Following is the soft variant: instead of being forced, a follower on the leader's cell schedules its own move in the same direction, at its own speed — which is why it can fall behind and catch up.

5. What "pseudo" means here

The simulation is real-time in design (fixed timestep, 25 ticks/second) but the sandbox advances it one tick per line of input, like a debugger for the world. The eventual goal — see TODO.md — was to split the loop into input/logic/output stages and put a network layer in front, turning this into an actual (small) MUD server.

Project layout

src/main/java/com/github/fabioticconi/pseudomud/
├── Main.java          entry point: world setup + console loop
├── actions/           Action base class + one class per verb
├── components/        the data: Pos, Speed, Moving, Enaction, triads...
├── constants/         Side, Stance, MoveTypes, BodyAffect
├── systems/           the logic: one system per interaction + Action/Input
└── utils/             world events (MOVED...)

Status

This is a cleaned-up release of a 2017 prototype: the composite-movement core works, reports its failures, and is tested; everything beyond it (map, visibility, real output, networking) is intentionally left as design notes in TODO.md. It is published in the hope that the patterns — action-as-event, capability triads, induced movement with delay sharing — are useful to anyone building simulation-heavy games on an ECS.

Issues and PRs are welcome, but expect a slow pace: this is an archive of ideas more than an active project.

License

Apache 2.0

About

An experiment in building the physical core of a MUD on top of an Entity-Component-System, using artemis-odb.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages