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.
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/./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.
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)
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.
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 ActionStatus
— BEING_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
StopActionis 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.
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).
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.
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.
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.
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...)
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.