| title | Nilpoint |
|---|---|
| subtitle | A Django engine for building adventure and narrative web games |
Nilpoint is an adventure game engine for Django. It allows the creation of games that involve exploration through multiple locations, an inventory and progression through conditions such as having collected an item, answered a riddle, etc.
Most of Nilpoint can be subclassed to add game-specific functionality, and multiple games can be run on a single server.
Games can be played by multiple people, each exploring their own version of the game world.
Nilpoint was created to enable the game "Cyperpunk", an adventure game of cryptography and cryptanalysis.
- Nilpoint is a reusable core package for adventure games.
- It provides shared models, views, templates, and release/update logic
- It is designed to be extended by game-specific apps
A Game in Nilpoint is a class that ties together all objects, player characters, locations, etc. Instances of each game object will be the same, but can have different players in different states of play.
Different kinds of game are created by subclassing the core Game object.
A Player is an adapter from whatever user model your Django project is using to Nilpoint. It doesn't currently do much other than sit between the user model and Nilpoint's models, but that may change in future.
A Player object is referred to by PlayerCharacter objects.
A PlayerCharacter is a Player's character in a given game. Games may allow a single Player to have multiple PlayerCharacters, or not. If Player plays multiple games, they will have a PlayerCharacter for each of them.
PlayerCharacters have a "handle" which is the name that the game will use for them. They also keep track of the location of the player in the game, and are referred-to by any records that relate to a single player in a given game, such as inventory items, etc.
When a new PlayerCharacter is created, it creates all of the records required by the intersection of a player and a game. This could be LocationItems, InventoryItems, etc.
A Location is a place in a Game. When a Game instance is created, all of its locations are instantiated too. So if you have two instances of a single game, the locations will be instantiated twice. This allows for some level of customisation between game instances, such as changing location names, graphics, etc.
An exit joins two locations in a single direction. There is a utility function (Exit.create_two_way_exit(...) to simplify making a bidirectional exit between two locations.
Items are ideals of things that exist in the world. If multiple players have item X in their inventory, they are all referring to one Item object for that game through individual InventoryItem objects. Similarly, if an item appears in a location, players interact with a LocationItem rather than the Item itself.
InventoryItem and LocationItem are how Items become visible, manipulable by players. If an Item for a game should exist at a location, each player will need a LocationItem that maps the Location to the Item. If they pick it up, the LocationItem for that player is deleted and an InventoryItem is created. Other players will see no effect.
GameAsset is an abstract class that classes related to a game inherit from. The purpose is to add a layer of unique ID to the asset that is unrelated to database IDs and in a scheme decided by the game creator. The goal is to allow assets to be found quickly in a way that makes sense to the creator. This is particularly important when creating new characters, updating game items, etc.
For example, we may need to give each new PlayerCharacter an Item. The Item will exist, but the unique DB key is likely to be different on the production system than it was on the developer system. The name of the item may also have changed since the game was instantiated. When the Item was created, along side the user-facing data such as it's name and description, and in addition to the automatic DB id assignment, it would have been given an asset_id, dictated by the GameAsset class. The Game class has a get_asset method that will find the right object based on this ID, restricted to objects related to the current game instance.
PlayerScoped is an abstract base class (mirror of GameAsset) for player-scoped state records like LocationItem and InventoryItem. It provides:
- A
pcForeignKey toPlayerCharacter(guaranteed on all subclasses) PlayerScopedManagerwithfor_character(pc)method for scoped queries- Convenience methods on
PlayerCharacter:items_at(location)andinventory_items()
New game state models should subclass PlayerScoped rather than reimplementing per-character filtering.
StatefulMixin provides per-instance state for any model (e.g., LocationItem, InventoryItem, future machines). It adds:
item_stateproperty — dict-like access to per-instance state (get,put,pop,keys,len,in, iteration)transfer_state_to(other)— copies state to another instancetransfer_item_state(from, to)— standalone helper for take/drop
State is stored in ItemState model (GenericForeignKey + pickled BinaryField), allowing flexible per-instance schemas without migrations.
New stateful models inherit StatefulMixin and automatically get item_state property.
Items can have interaction hooks attached, enabling game-specific actions (e.g., "squeeze", "tap", "read") without creating new models. Hooks are stored as a pickled dict on the Item model (template/archetype) and define three phases:
| Phase | Purpose | Returns |
|---|---|---|
show |
Render a partial for HTMX inclusion (e.g., a modal with action buttons) | Template path string (e.g., "mygame/interact/squeeze.jinja2#show") |
handle |
Execute the action when triggered | String message, HtmxTriggerResponse, or None |
can |
Gate the interaction (check state, location, etc.) | True/False |
Structure on Item.hooks:
{
"show": {"squeeze": "show_squeeze_wotsit"},
"handle": {"squeeze": "handle_squeeze_wotsit"},
"can": {"squeeze": "can_squeeze_wotsit"},
}Each value is a method name on the game instance (self in release steps, or game.get_real_instance() in views).
Usage in a game release step:
@release_step(3)
def add_wotsit_interactions(self):
wotsit = Item.objects.get(game=self, asset_id="WOTSIT")
wotsit.hooks.put("show", {"squeeze": "show_squeeze_wotsit"})
wotsit.hooks.put("handle", {"squeeze": "handle_squeeze_wotsit"})
wotsit.hooks.put("can", {"squeeze": "can_squeeze_wotsit"})Implement hook methods on your Game subclass:
class CypherpunkGame(Game):
def can_squeeze_wotsit(self, instance): # instance is LocationItem or InventoryItem
return instance.item_state.get("charges", 0) > 0
def show_squeeze_wotsit(self, instance):
return "cypherpunk/interact/wotsit_squeeze.jinja2#show"
def handle_squeeze_wotsit(self, instance, request):
instance.item_state.put("charges", instance.item_state.get("charges", 0) - 1)
return f"Squeezed! Charges left: {instance.item_state.get('charges', 0)}"Dispatch from the client (HTMX):
<!-- Show phase: render action UI -->
<button hx-post="{% nilpoint_action_url game 'interact' %}"
hx-vals='{"item_type": "location_item", "object_id": {{ li.id }}, "action": "squeeze", "phase": "show"}'
hx-target="#interaction-panel">
Squeeze
</button>
<!-- Handle phase: execute action -->
<button hx-post="{% nilpoint_action_url game 'interact' %}"
hx-vals='{"item_type": "location_item", "object_id": {{ li.id }}, "action": "squeeze", "phase": "handle"}'
hx-target="#messages">
Squeeze!
</button>Template tag for rendering available actions:
{% load nilpoint_tags %}
{# Renders buttons for all "show" hooks that pass their "can" check #}
{% nilpoint_interact_actions location_item "location_item" target="#interaction-panel" %}Default dispatch view:
The handle_interact view is registered as action "interact" in NilpointGameBasic. It handles all three phases via POST with parameters: item_type (location_item or inventory_item), object_id, action, phase.
A Nilpoint game is created by subclassing the models.Game class and any required related classes, and subclassing the views.NilpointGameBasic view.
The Game subclass deals with all of the game setup and various bits of behaviour while the view handles requests to interact with game state.
Some game classes get instantiated by core Nilpoint logic and subclasses need to be registered in order for this logic to use the correct one.
The Nilpoint settings module (nilpoint.nilpoint_settings) handles this.
- Django app package layout
- Model-first design
- View layer for game flow
- Templates and admin integration
- Settings-driven customization
- A game instance is represented as a Game object
- Each game can have multiple locations, exits, items, and player characters
- A player belongs to a user and may have characters in one or more games
- Core gameplay state is stored in database models, not in a custom runtime engine
- The game view stack loads the game by nilpoint_slug and renders common interaction states
- HTMX is used for partial UI updates and trigger-based UI responses
- Each game and player character carries a release number
- Release migrations are managed through @release_step
- This allows incremental data/model changes as game content evolves
- nilpoint_update_game can apply updates to existing game data
NilpointGameBasic provides a simple override mechanism for template partials. Each handler that renders a template checks for a corresponding class attribute on the view subclass; if present, that partial is used instead of the default.
# In your game's views.py
from nilpoint.views import NilpointGameBasic
class MyGameView(NilpointGameBasic):
# Override any of these to supply your own template partials:
landing_partial = "mygame/landing.jinja2#landing"
overview_partial = "mygame/overview.jinja2#overview"
location_graphic_partial = "mygame/location_graphic.jinja2#location_graphic"
player_location_panel = "mygame/player_location.jinja2#player_location_panel"
location_item_panel = "mygame/location_items.jinja2#location_item_panel"
item_detail_partial = "mygame/item_detail.jinja2#item_detail"
new_player_character_partial = "mygame/new_pc.jinja2#new_player_character"
new_player_character_submit = "mygame:new_pc_submit" # URL name for form POSTThe core view's _value_from_subclass_or_default(name, default) method does the lookup: it checks hasattr(self, name) and returns the subclass's value if it exists, otherwise the provided default string.
| Handler | Attribute | Default partial |
|---|---|---|
handle_landing |
landing_partial |
nilpoint/game_landing.jinja2#landing |
handle_overview |
overview_partial |
nilpoint/game_overview.jinja2#game_overview |
handle_get_location_graphic |
location_graphic_partial |
nilpoint/location_panel.jinja2#location_graphic |
handle_get_player_location_panel |
player_location_panel |
nilpoint/player_location_panel.jinja2#player_location_panel |
handle_get_location_item_panel |
location_item_panel |
nilpoint/location_item_panel.jinja2#location_item_panel |
handle_get_inventory_panel |
inventory_panel |
nilpoint/inventory_item_panel.jinja2#inventory_panel |
handle_item_detail |
item_detail_partial |
nilpoint/location_item_panel.jinja2#item_detail |
handle_new_player_character |
new_player_character_partial |
nilpoint/new_player_character.jinja2#new_player_character |
handle_new_player_character |
new_player_character_submit |
auto-derived dispatch URL |
This keeps handler logic in core while letting game apps swap templates freely.
Nilpoint provides template tags in nilpoint_tags to simplify common HTMX patterns:
{% load nilpoint_tags %}
{# Panel that loads via GET on trigger #}
{% nilpoint_panel "nilpoint-scene" "get_location_graphic"
triggers="load, player_location_changed from:body" %}
{# Action link with correct HTTP method #}
{% nilpoint_action "Detail" "item_detail" item=item.id
target="#nilpoint-item-detail-panel" %}
{% nilpoint_action "Take" "take_item" method="post" location_item=li.id %}
{# Form that GETs on load, POSTs on submit #}
{% nilpoint_form "new-pc-form" "new_player_character" %}Available tags:
| Tag | Purpose | Key Parameters |
|---|---|---|
nilpoint_panel |
HTMX panel div (GET) | panel_id, action, triggers, target, swap |
nilpoint_action |
Action link/button | text, action, method, target, swap, classes |
nilpoint_form |
Form (GET+POST) | form_id, action, method, target, swap |
All tags auto-generate the dispatch URL from the game in context.
- Overriding PlayerCharacter or other core models
- Registering game content types
- Using custom subclasses per game
- Install package
- Add to INSTALLED_APPS
- Add Nilpoint URLs
- Configure Django settings
- Set up NILPOINT_SETTINGS
- Creating a game instance
- Defining a game-specific player character subclass
- Registering game model classes
- Loading a game by slug
- Rendering the game landing page
- nilpoint_list_games
- nilpoint_update_game
- Example output and usage
- src/nilpoint/
- models.py
- views.py
- forms.py
- decorators.py
- nilpoint_settings.py
- admin.py
- templates/
- management/commands/
- tests/
- Early-stage package
- Requires a host Django project
- Not a standalone game server
- Still has placeholder/rough areas and TODOs
- Link to ATTRIBUTION.md
- Django and HTMX, etc.