This was a home assignment from one my potential employers. I pushed it a little further and developed a demo backend, with multiple options persistence and concurrency
- Python 3.14 (could work with lower version, not tested)
- uv for dependency management (installation)
- Redis, SQLite or PostgreSQL
Launch Redis, eg.:
docker-compose upInside project directory, install dependencies with:
uv syncRun the test suite:
uv run pytest -sRun the development server:
uv run fastapi devUsing env BACKEND=? you can set the persistence/locking backend:
battle_engine.backend.redis.impl:RedisBackendorbattle_engine.backend.sql.impl:SQLBackend
See .env for how to configure each.
After launching the server you can visit the OpenAPI documentation at http://localhost:8000/docs.
The stack this is built with:
- FastAPI & guvicorn for HTTP server. Concurrently handles player and battle requests using asyncio,
- Redis, or SQLite / PostgreSQL as a primary database
- Pydantic for request schema validation
Optionally, there is a backend that processes battles in a separate, scalable worker process based
on Taskiq and Redis. More on that later. (env WITH_TASKIQ=1)
The key decision here is to process the battles in the HTTP request handler immediately upon arrival. Thus the FIFO requirement, processing battles as they come, is satisfied. The fact that the battle logic is small, thus making the battle procssing low CPU and IO intensity.
This is achieved using Redis Lua scripting and watching player locking keys, ensuring no two players can engage in concurrent battles. Lua scripts in Redis are atomic, ensuring consistency.
In the SQL backend, consistency is achieved using serialized transactions and "UPSERT" statements for updating the leaderboard.
The app, although small, maps well to clean architectre layers:
- Entities - models.py (
PlayerBase,BattleRequest,BattleResult, etc.) - Use cases - logic.py (only requires above mentioned entities)
- Frameworks and Drivers - main.py as web framework, everything in backend for DB and persistence
This adds an extra level of indirection. Battles can be processed in a separate process, independently scalable from the main HTTP server. I don't see a valid reason for this extra IO (task -> Redis -> Worker; then Worker -> Redis -> Result to HTTP handler) in this case.
To run the worker process:
uv run taskiq worker battle_engine.tasks:brokerTo run the server with Redis + Taskiq support:
env WITH_TASKIQ=1 uv run fastapi runTo utilize the Redis + Taskiq backend, use the /battle_defer endpoint. You can also run the test
suite and API tests, using:
env WITH_TASKIQ=1 uv run pytest -s tests/test_api.pyToo keep things simple and within the scope of a home assignment, authentication is implemented
using an API key. A shared secret between the client and the server. It can be configured through
.env file or environment variables. It's using a conventional X-API-Key HTTP header to
authenticate the client.
You can run performance stress test using Locust
uv run locust -H http://localhost:8000And in another terminal, run the server with
uv run fastapi run # or with more parameters to play with (eg. --limit-concurrency) uv run
uvicorn battle_engine.main:app --host 0.0.0.0 --port 8000 --workers 4The specification misses a few crucial point:
- does not specify whether a player's hit points are persisted, or reset (to 100?) at the start of each battle
- does not specify the behavior where both players' defense value is high enough to miss the attack. In this case, the player's roles will switch infinitely. I mitigated this by setting a limit to number of turns per battle.