{"id":13568991,"url":"https://github.com/oestrich/kalevala","last_synced_at":"2025-04-05T23:10:55.027Z","repository":{"id":42836199,"uuid":"238809263","full_name":"oestrich/kalevala","owner":"oestrich","description":"A world builder's toolkit in Elixir","archived":false,"fork":false,"pushed_at":"2024-07-08T00:31:23.000Z","size":1437,"stargazers_count":179,"open_issues_count":8,"forks_count":10,"subscribers_count":7,"default_branch":"main","last_synced_at":"2025-03-29T22:06:56.768Z","etag":null,"topics":["elixir","mud"],"latest_commit_sha":null,"homepage":null,"language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/oestrich.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null},"funding":{"github":"oestrich","patreon":"ericoestrich"}},"created_at":"2020-02-06T23:56:31.000Z","updated_at":"2025-02-25T03:20:37.000Z","dependencies_parsed_at":"2024-11-05T01:31:44.695Z","dependency_job_id":"dc898de2-6ece-4353-a0c4-c644c4356f21","html_url":"https://github.com/oestrich/kalevala","commit_stats":{"total_commits":325,"total_committers":4,"mean_commits":81.25,"dds":"0.040000000000000036","last_synced_commit":"ac5972430d22bba0127b92e088871bba23940aae"},"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/oestrich%2Fkalevala","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/oestrich%2Fkalevala/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/oestrich%2Fkalevala/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/oestrich%2Fkalevala/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/oestrich","download_url":"https://codeload.github.com/oestrich/kalevala/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":247411235,"owners_count":20934653,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["elixir","mud"],"created_at":"2024-08-01T14:00:34.386Z","updated_at":"2025-04-05T23:10:55.004Z","avatar_url":"https://github.com/oestrich.png","language":"Elixir","funding_links":["https://github.com/sponsors/oestrich","https://patreon.com/ericoestrich"],"categories":["Elixir","Codebases"],"sub_categories":[],"readme":"![Elixir CI Status](https://github.com/oestrich/kalevala/workflows/Elixir%20CI/badge.svg)\n\n# Kalevala\n\n![Kalevala logo](kalevala.png)\n\nKalevala is a world building toolkit for text based games, written in Elixir.\n\n## Example Game\n\nThere is an example game, Kantele, in the `example/` folder.\n\nTo start the game:\n\n```bash\ncd example/\nmix deps.get\nmix compile\ncd assets/\nyarn install\nyarn build\ncd ..\nmix run --no-halt\n```\n\nA telnet listener will start on port `4444` and a TLS listener will start on port `4443` with a self signed cert. A web client will start on port `4500`, visit `http://localhost:4500/` to view it in your browser.\n\n## Components of Kalevala\n\n### Foreman\n\nWhen you connect, a new `Kalevala.Character.Foreman` process is started. This process handles incoming text from the player, sending out going text and events, and other orchestration.\n\n### Conn\n\nA `Kalevala.Character.Conn` is the context token for controllers and commands. This is similar to a `Plug.Conn`. The main difference being it bundles up multiple renders and events to fire all at once, instead of being used for a single request.\n\nThe foreman will generate new `Conn`s before each event or incoming text. After processing the `Conn`, it is processed by sending text to the player and events sent to their router (more on this in a bit).\n\n### Controllers\n\nA `Kalevala.Character.Controller` is the largest building block of handling texting. When starting the foreman, an initial controller is given. This controller is initialized and used from then on. The callbacks required will be called at the appropriate time with a new `Conn`.\n\nControllers act as a simple state machine, only allowing transitioning to the next one you set in the `Conn`. For instance, you can contain all login logic in a `LoginController`, and handle game commands in its own controller, any paging can be handled in a `PagerController` which can suppress any outgoing text to prevent scrolling while reading, etc.\n\n```elixir\ndefmodule Kantele.Character.CommandController do\n  use Kalevala.Character.Controller\n\n  require Logger\n\n  alias Kantele.Character.Commands\n  alias Kantele.Character.CommandView\n\n  @impl true\n  def init(conn), do: prompt(conn, CommandView, \"prompt\", %{})\n\n  @impl true\n  def recv(conn, \"\"), do: conn\n\n  def recv(conn, data) do\n    Logger.info(\"Received - #{inspect(data)}\")\n\n    case Commands.call(conn, data) do\n      {:error, :unknown} -\u003e\n        conn\n        |\u003e render(CommandView, \"unknown\", %{})\n        |\u003e prompt(CommandView, \"prompt\", %{})\n\n      conn -\u003e\n      prompt(conn, CommandView, \"prompt\", %{})\n    end\n  end\nend\n```\n\n### Commands\n\nA `Kalevala.Character.Command` is similar to a `Controller`, but should be called from a `Controller` through a `Command.Router`. Incoming text can be pattern matched in the router and be processed.\n\nIn the example below, you can `Kantele.Character.Commands.call(conn, \"say hello\")` to run the `SayCommand.run/2` function.\n\n```elixir\ndefmodule Kantele.Character.Commands do\n  use Kalevala.Character.Commands.Router, scope: Kantele\n\n  module(SayCommand) do\n    parse(\"say\", :run, fn command -\u003e\n      command |\u003e spaces() |\u003e text(:message)\n    end)\n  end\nend\n\ndefmodule Kantele.Character.SayCommand do\n  use Kalevala.Character.Command\n\n  def run(conn, params) do\n    params = %{\n      \"name\" =\u003e character(conn).name,\n      \"message\" =\u003e params[\"message\"]\n    }\n\n    conn\n    |\u003e render(SayView, \"echo\", params)\n    |\u003e event(\"room/say\", params)\n  end\nend\n```\n\n### Views\n\nA `Kalevala.Character.View` renders text and out of band events to the player. These are strings, IO data lists, or `Kalevala.Character.Conn.Event` structs (which are used for GMCP in telnet.)\n\nThe sigil `~i` keeps a string as an IO data list, which is faster for processing and should be used if any interpolation is needed. Larger views can use the sigil `~E` to use EEx.\n\n```elixir\ndefmodule Kantele.Character.SayView do\n  use Kalevala.Character.View\n\n  import IO.ANSI, only: [reset: 0, white: 0]\n\n  def render(\"echo\", %{\"message\" =\u003e message}) do\n    ~i(You say, \"\\e[32m#{message}\\e[0m\"\\n)\n  end\n\n  def render(\"listen\", %{\"character_name\" =\u003e character_name, \"message\" =\u003e message}) do\n    ~i(#{white()}#{character_name}#{reset()} says, \"\\e[32m#{message}\\e[0m\"\\n)\n  end\nend\n```\n\n### Events\n\nA `Kalevala.Event` is an internal event passed between processes. Events have three fields, which pid is generating the event, the topic (e.g. `room/say`), and a map of data. Controllers and commands can generate events which will get sent to an event router process, which is typically the room they are in.\n\nThe `Kalevala.World.Room` process handles the event by running the event through a similar router to command processing. The `Foreman` process handles events with its own event router.\n\nIn the example below, you can call the event router with an event of topic `room/say` to run the `Kantele.World.Room.NotifyEvent.call/2` function.\n\n```elixir\ndefmodule Kantele.World.Room.Events do\n  @moduledoc false\n\n  use Kalevala.Event.Router\n\n  scope(Kantele.World.Room) do\n    module(NotifyEvent) do\n      event(\"room/say\", :call)\n    end\n  end\nend\n\ndefmodule Kantele.World.Room.NotifyEvent do\n  import Kalevala.World.Room.Context\n\n  def call(context, event) do\n    Enum.reduce(context.characters, context, fn character, context -\u003e\n      event(context, character.pid, event.from_pid, event.topic, event.data)\n    end)\n  end\nend\n```\n\n### Actions\n\nA `Kalevala.Character.Action` is a small set of functionality that a character can perform. Think of this as an \"atom\" building block that you can piece together other commands and the behavior tree below together with.\n\nActions have a single function `run/2`. They accept a `conn` and `params` as commands and controllers do.\n\nThe example below bundles together what it means to speak into a channel, e.g. the room your character is in.\n\n```elixir\ndefmodule Kantele.Character.SayAction do\n  @moduledoc \"\"\"\n  Action to speak in a channel (e.g. a room)\n  \"\"\"\n\n  use Kalevala.Character.Action\n\n  alias Kantele.Character.SayView\n\n  @impl true\n  def run(conn, params) do\n    conn\n    |\u003e assign(:text, params[\"text\"])\n    |\u003e render(SayView, \"echo\")\n    |\u003e publish_message(params[\"channel_name\"], params[\"text\"], [], \u0026publish_error/2)\n  end\n\n  def publish_error(conn, _error), do: conn\nend\n```\n\n### Character \"Brains\" - Behavior Tree\n\nIn order to create non-player characters, you can equip them with a brain of sorts. This comes in the form of a simple behavior tree. The tree evaluates an incoming event and checks to see which branches/leaves trigger.\n\n#### Selectors\n\n- `FirstSelector`, starts at the top and tries each node, continuing until the first node succeeds\n- `ConditionalSelector`, starts at the top and tries each node, continuing as long as nodes succeed\n- `RandomSelector`, selects a random node to process\n- `Sequence`, starts at the top and runs each node, ignoring failing nodes\n\n#### Leaves\n\n- `Condition`, a node that evaluates the condition, if the condition is `false`, then the node fails, generally this is matched with a `ConditionalSelector` at the start to fail the actions below it\n- `Action`, a node that will append an action to the conn to be performed, optionally delayed\n- `NullNode`, a no-op node that can be used as an empty brain to skip node operation\n\n#### Conditions\n\n- `MessageMatch`, built-in condition to match a `Kalevala.Event.Message` event\n- `EventMatch`, built-in condition to match a base `Kalevala.Event` event\n\n### Communication \u0026 Channels\n\nA `Kalevala.Communication.Channel` is a pub/sub for sending chat messages between characters. Channels are registered with a callback module, allowing for callbacks before subscriptions/unsubscriptions/publishes.\n\nFor instance, rooms can register a channel for themselves, and as characters move around they can change room subscriptions. A `say` command can then publish to this room, with every other character in the room receiving the event via their subscription. Another example might be a global channel that all characters are subscribed to, or one for a set of characters.\n\nBy default, the `use` macro will fill in default implementations that allow all.\n\n```elixir\ndefmodule Kantele.Communication.BroadcastChannel do\n  use Kalevala.Communication.Channel\nend\n\ndefmodule Kantele.Communication do\n  @moduledoc false\n\n  use Kalevala.Communication\n\n  @impl true\n  def initial_channels() do\n    [{\"general\", Kantele.Communication.BroadcastChannel, []}]\n  end\nend\n```\n\n### The World\n\nThe world in Kalevala consists of `Kalevala.World.Zone`s and `Kalevala.World.Room`s. A zone contains many rooms, and rooms are the basic block of traversing the world. Rooms also act as the primary point of work (processing events) as all events will go through the room that characters are in.\n\nThe example game boots the world from flat files, but world data can come from any source as long as the structures can be created. For instance, you might load a simple zone struct with a database ID and hydrate the zone after the process started.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Foestrich%2Fkalevala","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Foestrich%2Fkalevala","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Foestrich%2Fkalevala/lists"}