Skip to content

Core Concepts

magmacrunchmedia edited this page Aug 24, 2026 · 3 revisions

Core Concepts

The shape of a game

Every texastoast game is the same three pieces:

game = Game(title="My Game", width=400, height=300, fps=30)

def update(dt):   # advance the world by dt seconds
    ...

def render():     # draw the current state
    ...

game.set_update(update)
game.set_render(render)
game.start()      # blocks until the window closes

update and render are separate on purpose: update owns state, render owns pixels. Keeping the split means you can call update from a test without a window.

The UI widgets rely on it. Since 0.3.0 DialogueBox advances from dialogue.update(dt) and all three widgets draw from their own render(), which you call after renderer.clear(). One consequence: UI animation is now driven by the loop, so stopping the loop freezes the typewriter — before 0.3.0 the dialogue ran on its own timer and kept typing regardless. See UI Components.

As of 0.5.0 the recommended shape for anything beyond a single screen is to hand both callbacks to a scene stack — game.set_update(stack.update); game.set_render(stack.render) — and let scenes own their slice of update/render. Pausing, menus and dialogue then stop being flags and become scenes. See Scenes and Game Structure.

Delta time

update receives dt — the seconds elapsed since the previous frame, as a float. Roughly 0.033 at 30 fps.

Every rate in your game should be multiplied by dt. A speed is pixels per second, not pixels per frame:

player.move(state.dx, state.dy, dt, tilemap)   # Entity does it for you
timer += dt                                     # and so should you

Without dt, a game runs at whatever speed the machine happens to manage, and the same code behaves differently at 30 and 60 fps. This is exactly the bug that 0.2.0 fixed — see Migrating to 0.2.0.

dt is clamped

GameLoop.MAX_DT is 0.1. If a frame takes longer than that — you dragged the window, hit a breakpoint, the machine slept — dt is capped at 0.1 rather than reporting the true gap.

The alternative is worse: an unclamped dt of 5 seconds moves a 100 px/s entity 500 px in one step, straight past whatever was in its way. Clamping means the world briefly runs in slow motion instead of exploding.

The loop

GameLoop schedules itself with tkinter's after(). It is not a busy loop and not a thread — it cooperates with the tkinter event loop, which is what lets key bindings and canvas updates work at all.

game.loop.fps           # measured frames per second, updated once a second
game.loop.frame_count   # frames since the last fps sample

The scheduled delay accounts for the time update and render just spent, so a 30 fps game targets a 33 ms cadence rather than 33 ms plus however long your frame took.

An exception raised inside update or render is logged and swallowed — one bad frame does not kill the game. Configure the texastoast logger to see them:

import logging
logging.basicConfig(level=logging.DEBUG)

Shutting down

Game handles the window's close button, so clicking X stops the loop cleanly rather than leaving a callback pending. Register your own cleanup with on_close:

keyboard = KeyboardInput(game.root)
game.on_close(keyboard.destroy)

Callbacks run in registration order, and one raising does not stop the rest. quit() is idempotent — calling it twice is safe.

Config

Config is a plain dataclass. Game builds one from its keyword arguments, or takes one you supply:

from texastoast import Config, Game

cfg = Config(title="My Game", width=640, height=480, fps=60, bg_color="#000")
game = Game(config=cfg)

Fields: title, width, height, fps, tile_size, bg_color, grid_color, debug. Only the first five affect the window today; the rest are there for your own use.

Embedding

Game normally creates and owns its Tk window. Hand it an existing root or frame and it builds into that instead, leaves the main loop to you, and does not destroy the widget on quit():

frame = tk.Frame(my_app)
game = Game(width=640, height=480, root=frame)
game.start()      # starts the game loop only
my_app.mainloop() # you own this

This is also what makes the engine testable without a visible window.

Clone this wiki locally