Patchwork is a lightweight tool for visualizing 2D environments, built with Python. It serves as a testbed for experimenting with graphical rendering of grids and virtual environments using various graphics libraries—starting with Pygame.
This project is part of the Viron ecosystem and provides a visual layer to its simulated environments.
python main.py 20 against a real local Viron server: the "Creating environment" screen, then the render loop re-colouring each of the 400 locations at random every frame (slowed down here; the real loop runs at 60 FPS).
- Grid-based rendering of 2D environments
- Initial support for Pygame
- Caching of created environments in
environments.jsonso a grid size can be re-loaded instead of re-created - Modular structure designed for future support of other graphics libraries
- Clean interface for testing Viron entity placement and behavior
- RenderWindow class for simplified Pygame window management
- Text rendering of an environment's locations, with an example console application (see Text rendering)
- Anonymous usage reporting (
startupandenvironment-createdevents with the program name and version) with an opt-out insettings.jsonor the environment (see Usage reporting)
Patchwork provides a RenderWindow class that encapsulates Pygame initialization and window management. This class is designed for composition, not inheritance, making it easy to integrate into projects without subclassing.
- Automatic Pygame initialization
- Window and surface management
- Event loop handling with custom event handlers
- Frame rate control
- Teardown via
close()or the context-manager protocol - Optional user-resizable window
- Clean API for common rendering operations
from render_window import RenderWindow
import pygame
# Create window
window = RenderWindow("My Application", 800, 600)
surface = window.get_surface()
# Register custom event handlers
def handle_input(event):
if event.type == pygame.KEYDOWN:
print(f"Key pressed: {event.key}")
window.register_event_handler(handle_input)
# Main loop
while window.should_continue():
surface.fill((0, 0, 0))
# ... render your content ...
pygame.display.update()
window.tick(60) # 60 FPS
window.close()close() shuts Pygame back down and marks the window as no longer running, so any
further call to should_continue() returns False. The window can also be used as a
context manager, which tears it down even if the loop body raises:
with RenderWindow("My Application", 800, 600) as window:
surface = window.get_surface()
while window.should_continue():
# ... render your content ...
pygame.display.update()
window.tick(60)Passing resizable=True lets the user resize the window. Pygame resizes the display
surface in place, so the surface from get_surface() stays valid; surface.get_size()
reports the current dimensions and should be read each frame to lay content out:
window = RenderWindow("My Application", 800, 600, resizable=True)main.py uses RenderWindow for its own window and render loop, so it doubles as a
worked example of integrating the class with Graphik.
Graphik (in graphik.py) is the small drawing helper that main.py renders with. It is
constructed around an existing display surface, such as the one from
RenderWindow.get_surface(), and creates no window of its own:
from graphik import Graphik
graphik = Graphik(window.get_surface())
graphik.drawRectangle(10, 10, 100, 50, (200, 0, 0))
graphik.drawText("Hello", 400, 300, 20, "white")
graphik.drawButton(350, 500, 100, 40, (0, 0, 200), "white", 16, "Click", on_click)drawRectangle(xpos, ypos, width, height, color)fills a rectangle whose top-left corner is at(xpos, ypos).drawText(text, xpos, ypos, size, color)renderstextin Pygame's bundledfreesansbold.ttffont at the given point size, centred on(xpos, ypos).drawButton(xpos, ypos, width, height, colorBox, colorText, sizeText, text, function)draws a box with its text centred on it, and callsfunctionwhen the left mouse button goes down while the pointer is inside the box. It is meant to be called once per frame: holding the button down firesfunctiononly once, and a press that started elsewhere and is dragged onto the box does not fire it. Each button's press state is tracked separately, keyed by its position and size.
Colours may be RGB tuples or any colour name Pygame recognises, such as "white" or "red".
renderLocations (in text_renderer.py) turns a list of locations into a string, so an environment can be shown without a window. It does not import Pygame:
from text_renderer import renderLocations
print(renderLocations(locations))
print(renderLocations(locations, filled="X", empty="_"))Each location needs only get_x() and get_y(), as in the locations from Viron's LocationService. As in the Pygame window, x increases to the right and y increases downwards. The grid is sized from the locations themselves, up to the largest x and y, and a cell with no location shows the empty text. Cells are separated by a space, since a terminal character is about twice as tall as it is wide. An empty list renders as an empty string.
console_view.py is an example console application built on it, which prints an environment that already exists in Viron:
python console_view.py 7# # #
# # #
# # #
The environment ID is the required argument; environments.json records the ID of each environment created by main.py. The Viron server is expected at the same address as for main.py (see Starting Viron). The application uses Viron's LocationService, so it needs the Viron/ submodule and Requests, but not Pygame.
The exit status is 0 once the environment is printed. It is 1, with a message, if the locations cannot be fetched or there are none, which is also how an ID unknown to Viron shows up, and 2, with a usage line, if the argument is missing, is not an integer, or is followed by further arguments. --help or -h prints the usage line and exits with status 0 without contacting Viron.
Nothing is created in Viron, no file is written, and no usage is reported. Viron gives every grid the same coordinates, so the grids of an environment with several would be drawn on top of each other; environments created by main.py have one.
- Python 3.9+, since Viron's
EnvironmentServiceuses built-in generic annotations such aslist[Environment], which older interpreters reject on import - Pygame (
pip install pygame) - Requests (
pip install requests), which Viron'sEnvironmentServiceandLocationServiceuse to reach the Viron server - Viron, which is vendored as a Git submodule and is also expected to be running as a server (see below)
- Docker, if Viron is to be started from the bundled Compose file
Clone the repository along with the Viron submodule:
git clone --recurse-submodules https://github.com/Preponderous-Software/patchwork.git
cd patchwork
pip install pygame requestsIf the repository was already cloned without --recurse-submodules, the submodule can be populated afterwards:
git submodule update --init --recursiveThe submodule is required at runtime: main.py imports Viron's EnvironmentService and LocationService from the Viron/ directory.
By default, Patchwork expects a Viron server to be reachable at http://localhost:9999. On Windows, the bundled batch scripts start and stop it:
up.bat
down.batThe equivalent commands on other platforms are:
docker compose -f Viron/compose.yml up -d --build
docker compose -f Viron/compose.yml down --remove-orphans --volumesNote that down.bat passes --volumes, so stopping Viron this way also deletes its database volumes. Any environments recorded in environments.json will no longer resolve afterwards (see Running for what happens then).
To reach a Viron server elsewhere, set either or both of these environment variables, which main.py and console_view.py both read:
PATCHWORK_VIRON_URL, the URL including its scheme and without the port, which defaults tohttp://localhostPATCHWORK_VIRON_PORT, which defaults to9999
PATCHWORK_VIRON_URL=https://viron.example PATCHWORK_VIRON_PORT=8080 python main.pyA variable that is unset, empty or only whitespace leaves its default in place, and a trailing slash on the URL is ignored. A port that is not an integer from 1 to 65535 prints a message and falls back to 9999. up.bat and down.bat still manage only the local server from the bundled Compose file, whichever address is set.
environments.json records each environment by its grid size alone, not by the server it was created on. After pointing Patchwork at a different Viron, the file should be deleted, or Patchwork run from another working directory, since a recorded ID could otherwise fail to load or resolve to an unrelated environment on the new server.
To launch the Patchwork visualization:
python main.pyAn optional first argument sets the grid size, which defaults to 50. A value that cannot be parsed as an integer, or one below 1, also falls back to 50, so no environment is created for a grid that could not be rendered.
python main.py 100Passing --exit-after-create as the second argument renders a newly created environment once and then exits after roughly two seconds, instead of entering the render loop. It has no effect when the requested grid size is already cached in environments.json, since no environment is created in that case. Because the flag is read positionally, a grid size must be supplied before it:
python main.py 100 --exit-after-createPassing --help or -h in any position prints a usage line and exits with status 0, before a window is opened, Viron is contacted, a file is written or usage reporting starts:
python main.py --helpCreated environments are recorded in environments.json, keyed by grid count and grid size (for example 1x50; the grid count is currently fixed at 1). Each entry records the environment's environment_id, grid_size, num_grids and creation_time_seconds. A key that is already present in that file is re-loaded from Viron rather than re-created, so the file should be deleted to force re-creation.
If a recorded environment cannot be loaded — because the Viron server is unreachable, or because it no longer knows the recorded ID — the error is printed, Error loading environment, please check logs. is shown in the window for about two seconds, and the program exits with status 0 without re-creating the environment. Deleting environments.json (or just the affected entry) lets the next run create it afresh.
Creating an environment is not guarded in the same way. If the Viron server is unreachable or rejects the request, the exception propagates out of main(): the program exits with a traceback and a non-zero status while Creating environment, please wait... is still shown, nothing is added to environments.json, and no environment-created event is reported. create_environments.bat relies on that non-zero status to print Error occurred while running with size <n>., with the traceback going to error_log.txt.
Only the load itself is guarded. The other failures below also propagate out of main(), so the program exits with a traceback and a non-zero status, with no error drawn in the window:
- If
environments.jsonis empty or is not valid JSON, the failure comes before Viron is contacted and the file is left as it is. Deleting the file lets the next run start afresh. - If
environments.jsonholds valid JSON that is not an object, such as[], the failure comes only after the environment has been created through Viron. The new environment is not recorded and the file is left as it is, so every further run creates another one until the file is deleted. - If the entry for the requested grid size has no
environment_id, the failure comes before Viron is contacted, so the load error above is not shown. Deleting the entry lets the next run create it afresh. - If fetching an environment's locations fails, the environment stays recorded in
environments.json, so the next run loads it rather than creating another. For a new environment, theenvironment-createdevent has already been reported by then.
The window opens at 800x800 and can be resized. The render loop scales the grid to the window's current size on every frame, so the whole environment stays visible; the cells are stretched rather than kept square when the window is not square.
The render loop is capped at 60 frames per second via RenderWindow.tick(). Since each location is re-coloured at random on every frame, that cap is also what sets the rate at which the visualization re-randomizes.
main.py guards its entry point with if __name__ == "__main__":, so the module can be imported — by the test suite, or by another program wanting to call main(gridSize, exitAfterCreate) directly — without launching a window. main() also accepts locationService and environmentService arguments, which default to services pointed at the address described in Starting Viron, and a reportUsage argument, which defaults to False. The CLI entrypoint passes reportUsage=True; a direct main() call that leaves it at the default starts no usage reporting and does not create or modify settings.json.
On Windows, create_environments.bat deletes environments.json and then invokes python main.py <size> --exit-after-create once per grid size, from 1 up to the maximum size given as its first argument (defaulting to 100). Standard output is appended to output.txt and errors to error_log.txt.
create_environments.bat 25Unit tests live in tests/ and use only the standard library's unittest. They stand in for Viron's service modules with stubs registered in sys.modules, so no display, no running Viron server, and not even a populated Viron/ submodule are required. The tests for main.py, render_window.py and graphik.py mock Pygame; tests/test_headless_run.py also runs the headless harness described below end to end against the real Pygame using SDL's dummy drivers, and the tests for text_renderer.py, console_view.py, viron_address.py, usage_reporting.py and trace_client.py do not involve Pygame at all. tests/test_batch_scripts.py reads the Windows batch scripts as text, so it needs no Windows either. Because Viron is stubbed rather than imported, the suite also runs on Python versions older than the 3.9 that main.py itself needs:
python -m unittest discover -s testsRun the command from the repository root, so that the top-level modules the tests import (main.py, render_window.py, graphik.py, headless_run.py, text_renderer.py, console_view.py, viron_address.py, usage_reporting.py and trace_client.py) are importable.
The same command, preceded by a py_compile syntax check of every top-level .py file in the repository root, is run by the Tests GitHub Actions workflow (.github/workflows/tests.yml) on every pull request targeting main and on every push to main. The workflow checks the repository out without the Viron submodule, since the suite does not need it.
headless_run.py drives main() end to end when neither a Viron server nor a display is available. The real Pygame, RenderWindow and Graphik are used, rendering to SDL's dummy video and audio drivers (unless SDL_VIDEODRIVER or SDL_AUDIODRIVER is already set); only Viron is replaced, by in-memory services with one location per grid cell. Like the test suite, it needs neither the Viron/ submodule nor Python 3.9+.
python headless_run.py [gridSize] [frames]gridSize defaults to 10 and frames, the number of render-loop frames to draw per scenario, to 30. Three scenarios are run, each in its own temporary directory so that the repository's environments.json is neither read nor written:
create-and-exit— a new environment is created and rendered once, as with--exit-after-create(the two-second pause is skipped)create-and-render— a new environment is created and the render loop runsload-and-render— an environment already recorded inenvironments.jsonis loaded and the render loop runs
Each scenario prints PASS or FAIL and, for the render loop, the measured frame rate. A scenario fails if main() raises, if no entry is recorded in environments.json, if the render loop does not draw the requested number of frames, or if Pygame is left initialised. The exit status is 0 when every scenario passes, 1 otherwise, and 2 for invalid arguments. This exercises everything in main.py except the real HTTP calls to Viron, which still need a running server (see Starting Viron).
The GIF at the top was recorded from an unmodified python main.py 20 run under a virtual X display (Xvfb), with frames grabbed by ImageMagick import. The Viron server was the one in the Viron/ submodule at its pinned commit, built with Maven and run against a throwaway local PostgreSQL initialised from Viron/db-scripts/setup/. Usage reporting was turned off with DO_NOT_TRACK=1.
- Visualization of entity grids and spatial data from Viron
- Debugging simulations in real-time
- Prototyping user interfaces or tile-based systems
- Educational demos of 2D virtual environments
- Add support for other graphics libraries (Tkinter, OpenGL, etc.)
- Interactive toggling of cell states
- Layered rendering and animation
- Customizable grid styling
- Real-time interaction with live Viron simulations
Usage reporting is on by default: Patchwork sends its name (patchwork), its version from
version.txt and the events startup (once per launch) and environment-created (when a new
environment is created through Viron) to trace
at https://trace.danielstephenson.dev, so that it is known which versions are in use. If
version.txt is missing, cannot be opened or is empty, unknown is sent as the version instead. Every
event also carries a random installation ID (the tag install) so installations can be counted
rather than events; beyond that, nothing about you, your machine, your IP address, the grid size or
the environments is sent. The report
is made from a background thread, never blocks the program and never raises; if the service is
unreachable the event is simply dropped.
Patchwork ships with its program key (a key identifies the program to trace; it is not a
secret). On first launch, the CLI prints a one-line notice and writes this block to
settings.json in the working directory (the same place as environments.json), after which the
notice is not shown again:
{
"usage_reporting": {
"enabled": true,
"endpoint": "https://trace.danielstephenson.dev",
"key": "oAMBqZC_yjTIqlB86_O7G4ZZ3gWuGBCBLJLFbUuFaoU"
}
}If settings.json already exists, the block is added alongside its other entries. If it exists
but cannot be read, or does not hold a JSON object, a message saying so is printed, the file is
left untouched, and nothing is reported for that run. When
TRACE_USAGE_REPORTING or DO_NOT_TRACK already opts out (see below), the block is still written,
but the notice instead reads Usage reporting is off (environment). Details: followed by the
details link.
The installation ID is a random UUID kept in a file named trace-install-id in the user data
directory: ~/.local/share/patchwork/ on Linux (or $XDG_DATA_HOME/patchwork/),
~/Library/Application Support/patchwork/ on macOS and %APPDATA%\patchwork\ on Windows. It
identifies no person, account or address; delete the file to get a new one. Setting the environment
variable TRACE_INSTALL_ID sends that value instead and leaves the file alone. The file is only
created while reporting is on, so every opt-out below also stops it.
To turn reporting off, any one of these is enough:
-
set
enabledtofalseinsettings.json:{ "usage_reporting": { "enabled": false } } -
set the environment variable
TRACE_USAGE_REPORTING=off(alsofalse,0,no), which turns off every program that reports to trace -
set the environment variable
DO_NOT_TRACK=1(alsotrue,yes; see consoledonottrack.com)
For both variables, case and surrounding whitespace are ignored, and any other value (including an
empty one) leaves settings.json in charge.
The environment variables win over settings.json. endpoint selects the trace server and key
the key reports are sent with; the PATCHWORK_USAGE_REPORTING_KEY environment variable, when
set, overrides the key in settings.json and the shipped one. The client lives in
trace_client.py, vendored from
trace-client-python with only the
header note adjusted for Patchwork, and the settings handling in usage_reporting.py.
Details: https://danielstephenson.dev/usage-reporting
This project is licensed under the Preponderous Non-Commercial License (Preponderous-NC).
It is free to use, modify, and self-host for non-commercial purposes, but commercial use requires a separate license.
Disclaimer: Preponderous Software is not a legal entity.
All rights to works published under this license are reserved by the copyright holder, Daniel McCoy Stephenson.
Full license text:
https://github.com/Preponderous-Software/preponderous-nc-license/blob/main/LICENSE.md
Created by Daniel McCoy Stephenson as part of the Preponderous ecosystem.
