diff --git a/src/Ecs/Lockstep.ts b/src/Ecs/Lockstep.ts
new file mode 100644
index 0000000..c9e54ec
--- /dev/null
+++ b/src/Ecs/Lockstep.ts
@@ -0,0 +1,93 @@
+
+export interface DeepCopy<T> {
+    deepCopy(patch: Partial<T>): T;
+}
+
+export interface Equals {
+    equals(other: this): boolean;
+}
+
+function equals<T extends Equals | string | number>(a: T, b: T): boolean {
+    if(typeof a === "string"){
+        return a == b;
+    }
+    if(typeof a === "number"){
+        return a == b;
+    }
+    return (a as Equals).equals(b as Equals);
+}
+
+// TODO: probably an incoherent idea. Instead consider having two state objects,
+// a synchronized state object that can be rolled back / predicted, and
+// a cosmetic state object that glosses the synchronized state and never rolls back
+export const enum TickType {
+    /// a "canonical" update that will not be rolled back;
+    /// this must be fully deterministic based on the state and input.
+    CANON,
+    /// a "predicted" update that may or may not be rolled back;
+    /// if possible, avoid changes that could be distracting if rolled back.
+    PREDICTED,
+}
+
+export type Advancer<Input, State> = (state: State, input: Input) => void;
+
+export class LockstepState<Input extends Equals, State extends DeepCopy<State>> {
+
+    private inputIndex = -1;
+    private inputLog: Input[] = [];
+    private canonIndex = -1;
+    private canonState: State;
+    private renderIndex = -1;
+    private renderState: State;
+
+    constructor(private initialState: State, private advancer: Advancer<Input, State>) {
+        this.canonState = initialState.deepCopy({});
+        this.renderState = initialState.deepCopy({});
+    }
+
+    public addCanonInput(input: Input): void {
+        this.canonIndex++;
+
+        // advance canonical game state
+        this.advancer(this.canonState, input);
+
+        if(this.canonIndex <= this.renderIndex) {
+            // we're rendering predicted states, so if the input changes we need to invalidate the rendered state
+            if(!equals(this.inputLog[this.canonIndex], input)) {
+                this.renderState = this.canonState.deepCopy({});
+                this.renderIndex = this.canonIndex;
+            }
+        }
+        this.inputLog[this.canonIndex] = input;
+    }
+
+    public addLocalInput(input: Input): void {
+        this.inputIndex++;
+
+        // ensure that we don't overwrite the canon input with local input somehow
+        // (probably only possible in situations where game is unplayable anyways? but still for sanity.)
+        if(this.inputIndex > this.canonIndex) {
+            this.inputLog[this.inputIndex] = input;
+        }
+    }
+
+    /**
+     * Do any necessary simulation to catchup the predicted game state to the frame
+     * that should be rendered, then return it.
+     */
+    public getStateToRender(): State {
+        // TODO: input lag by X frames
+        const targetIndex = this.inputLog.length - 1;
+
+        while(this.renderIndex < targetIndex) {
+            this.renderIndex++;
+            this.advancer(this.renderState, this.inputLog[this.renderIndex]);
+        }
+
+        return this.renderState;
+    }
+}
+
+export class LockstepLoop<Input, State extends DeepCopy<State>> {
+
+}